more stuff

This commit is contained in:
Simon Martens
2026-03-04 16:39:47 +01:00
parent 5a00333266
commit d7f7571470
22 changed files with 620 additions and 482 deletions

View File

@@ -18,6 +18,7 @@ type briefPageModel struct {
Number int Number int
Prev int Prev int
Next int Next int
Heading letterHeadModel
Pages []briefRenderPage Pages []briefRenderPage
} }
@@ -57,7 +58,9 @@ func (s *Server) Brief(c *echo.Context) error {
return c.String(http.StatusNotFound, "brief not found") return c.String(http.StatusNotFound, "brief not found")
} }
meta := s.app.Library().Metas.Item(num)
model := renderBrief(*letter) model := renderBrief(*letter)
model.Heading = buildLetterHead(s.app.Library(), meta)
model.Prev, model.Next = briefNeighbors(s.app, num) model.Prev, model.Next = briefNeighbors(s.app, num)
var out bytes.Buffer var out bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&out, "brief", model); err != nil { if err := s.tmpl.ExecuteTemplate(&out, "brief", model); err != nil {

View File

@@ -19,5 +19,7 @@ func MapStatic(e *echo.Echo) {
} }
func MapEndpoints(e *echo.Echo, s *Server) { func MapEndpoints(e *echo.Echo, s *Server) {
e.GET("/", s.Home)
e.GET("/briefe", s.Home)
e.GET("/brief/:number", s.Brief) e.GET("/brief/:number", s.Brief)
} }

114
server/letterhead.go Normal file
View File

@@ -0,0 +1,114 @@
package server
import (
"strconv"
"strings"
"github.com/Theodor-Springmann-Stiftung/lenz-web/xmlmodels"
)
type letterHeadModel struct {
Number int
DateText string
IsDraft bool
HasOriginal bool
Pairs []letterHeadPairModel
}
type letterHeadPairModel struct {
Sent string
Received string
}
func buildLetterHead(lib *xmlmodels.Library, meta *xmlmodels.Meta) letterHeadModel {
head := letterHeadModel{
Pairs: make([]letterHeadPairModel, 0),
}
if meta == nil {
return head
}
head.Number = meta.Letter
head.IsDraft = meta.IsDraft.IsTrue()
head.HasOriginal = meta.HasOriginal.IsTrue()
if earliest := meta.Earliest(); earliest != nil {
head.DateText = strings.TrimSpace(earliest.Text)
}
for _, pair := range meta.SendReceivedPairs() {
sentNames := resolveNames(lib, pair.Sent.Persons, true)
receivedText := "Unbekannt"
if pair.Received != nil {
receivedNames := resolveNames(lib, pair.Received.Persons, true)
receivedPlaces := resolveNames(lib, pair.Received.Places, false)
if len(receivedNames) > 0 {
receivedText = joinGerman(receivedNames)
} else if len(receivedPlaces) > 0 {
receivedText = joinGerman(receivedPlaces)
} else {
receivedText = ""
}
if len(receivedPlaces) > 0 && len(receivedNames) > 0 {
receivedText += " (" + joinGerman(receivedPlaces) + ")"
}
}
head.Pairs = append(head.Pairs, letterHeadPairModel{
Sent: joinGerman(sentNames),
Received: receivedText,
})
}
return head
}
func resolveNames(lib *xmlmodels.Library, refs []xmlmodels.RefElement, person bool) []string {
ret := make([]string, 0, len(refs))
for _, ref := range refs {
name := ""
if person {
if def := lib.Person(ref.Reference); def != nil {
name = strings.TrimSpace(def.Name)
if name == "" {
name = strings.TrimSpace(def.FirstName + " " + def.LastName)
}
}
} else {
if def := lib.Place(ref.Reference); def != nil {
name = strings.TrimSpace(def.Name)
}
}
if name == "" {
name = strings.TrimSpace(ref.Text)
}
if name == "" && ref.Reference > 0 {
if person {
name = "Person " + strconv.Itoa(ref.Reference)
} else {
name = "Ort " + strconv.Itoa(ref.Reference)
}
}
if name != "" {
ret = append(ret, name)
}
}
return ret
}
func joinGerman(items []string) string {
switch len(items) {
case 0:
return ""
case 1:
return items[0]
case 2:
return items[0] + " und " + items[1]
default:
return strings.Join(items[:len(items)-1], ", ") + " und " + items[len(items)-1]
}
}

107
server/letters.go Normal file
View File

@@ -0,0 +1,107 @@
package server
import (
"bytes"
"net/http"
"strings"
"github.com/Theodor-Springmann-Stiftung/lenz-web/xmlmodels"
"github.com/labstack/echo/v5"
)
type dateRange struct {
Label string
Start int
End int
Letters []letterHeadModel
}
type lettersPageModel struct {
Ranges []dateRange
SelectedRange string
ShowAll bool
ActiveRanges []dateRange
}
func (s *Server) Home(c *echo.Context) error {
rangeParam := c.Request().URL.Query().Get("range")
if rangeParam == "" {
rangeParam = "all"
}
model := buildLettersPageModel(s.app.Library(), rangeParam)
var out bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&out, "home", model); err != nil {
return c.String(http.StatusInternalServerError, "template render failed: "+err.Error())
}
return c.HTML(http.StatusOK, out.String())
}
func buildLettersPageModel(lib *xmlmodels.Library, rangeParam string) lettersPageModel {
ranges := []dateRange{
{Label: "1756-1770", Start: 1756, End: 1770, Letters: []letterHeadModel{}},
{Label: "1771-1775", Start: 1771, End: 1775, Letters: []letterHeadModel{}},
{Label: "1776", Start: 1776, End: 1776, Letters: []letterHeadModel{}},
{Label: "1777-1779", Start: 1777, End: 1779, Letters: []letterHeadModel{}},
{Label: "1780-1792", Start: 1780, End: 1792, Letters: []letterHeadModel{}},
}
years, yearMap := lib.Years()
for _, year := range years {
letters, ok := yearMap[year]
if !ok {
continue
}
target := -1
for i := range ranges {
if year >= ranges[i].Start && year <= ranges[i].End {
target = i
break
}
}
if target == -1 {
continue
}
for _, meta := range letters {
metaCopy := meta
ranges[target].Letters = append(ranges[target].Letters, buildLetterHead(lib, &metaCopy))
}
}
selected := strings.TrimSpace(rangeParam)
if selected == "" {
selected = "all"
}
model := lettersPageModel{
Ranges: ranges,
SelectedRange: selected,
ShowAll: selected == "all",
ActiveRanges: make([]dateRange, 0, len(ranges)),
}
if model.ShowAll {
for _, r := range ranges {
if len(r.Letters) > 0 {
model.ActiveRanges = append(model.ActiveRanges, r)
}
}
return model
}
for _, r := range ranges {
if r.Label == selected && len(r.Letters) > 0 {
model.ActiveRanges = append(model.ActiveRanges, r)
return model
}
}
model.ShowAll = true
model.SelectedRange = "all"
for _, r := range ranges {
if len(r.Letters) > 0 {
model.ActiveRanges = append(model.ActiveRanges, r)
}
}
return model
}

View File

@@ -0,0 +1,27 @@
{{ define "letterhead" }}
<div class="letterhead">
<div class="letterhead-meta">
{{ if .DateText }}
<div class="letterhead-date">{{ .DateText }}</div>
{{ end }}
<div class="letterhead-badges">
{{ if .IsDraft }}
<span class="letter-badge" title="Entwurf"><i class="ri-draft-line"></i></span>
{{ end }}
{{ if .HasOriginal }}
<span class="letter-badge" title="Der Brieftext wurde anhand des Originals kritisch geprüft."><i class="ri-file-list-2-line"></i></span>
{{ else }}
<span class="letter-badge" title="Der Brieftext wurde sekundär überliefert."><i class="ri-file-copy-2-line"></i></span>
{{ end }}
</div>
</div>
{{ range .Pairs }}
<div class="letterhead-row">
<div class="letterhead-side">{{ .Sent }}</div>
<div class="letterhead-arrow"><i class="ri-arrow-right-long-line"></i></div>
<div class="letterhead-side">{{ .Received }}</div>
</div>
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,9 @@
{{ define "letterlist" }}
<div class="letter-list">
{{ range . }}
<a class="letter-list-item" href="/brief/{{ .Number }}">
{{ template "letterhead" . }}
</a>
{{ end }}
</div>
{{ end }}

View File

@@ -5,6 +5,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}Default{{ end }}</title> <title>{{ block "title" . }}Default{{ end }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css">
<link rel="stylesheet" href="/public/style.css"> <link rel="stylesheet" href="/public/style.css">
</head> </head>
<body> <body>

View File

@@ -4,23 +4,27 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Brief {{ .Number }}</title> <title>LKB - {{ .Number }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css">
<link rel="stylesheet" href="/public/style.css"> <link rel="stylesheet" href="/public/style.css">
</head> </head>
<body> <body>
<main class="brief-page"> <main class="brief-page">
<header class="brief-head"> <header class="brief-head brief-head-letter">
<h1>Brief {{ .Number }}</h1> <div class="brief-head-left">
{{ template "letterhead" .Heading }}
</div>
<nav class="brief-nav"> <nav class="brief-nav">
{{ if gt .Prev 0 }} {{ if gt .Prev 0 }}
<a class="brief-nav-btn" href="/brief/{{ .Prev }}">← Brief {{ .Prev }}</a> <a class="brief-nav-btn" href="/brief/{{ .Prev }}"><i class="ri-arrow-left-long-line"></i></a>
{{ else }} {{ else }}
<span class="brief-nav-btn is-disabled">← Kein früherer</span> <span class="brief-nav-btn is-disabled"><i class="ri-arrow-left-long-line"></i></span>
{{ end }} {{ end }}
<a class="brief-nav-btn brief-nav-mid" href="/">LKB</a>
{{ if gt .Next 0 }} {{ if gt .Next 0 }}
<a class="brief-nav-btn" href="/brief/{{ .Next }}">Brief {{ .Next }} →</a> <a class="brief-nav-btn" href="/brief/{{ .Next }}"><i class="ri-arrow-right-long-line"></i></a>
{{ else }} {{ else }}
<span class="brief-nav-btn is-disabled">Kein späterer →</span> <span class="brief-nav-btn is-disabled"><i class="ri-arrow-right-long-line"></i></span>
{{ end }} {{ end }}
</nav> </nav>
</header> </header>

View File

@@ -1,8 +1,31 @@
{{ define "home" }}{{ template "layout" . }}{{ end }} {{ define "home" }}{{ template "layout" . }}{{ end }}
{{ define "title" }}Home{{ end }} {{ define "title" }}Lenz-Briefe{{ end }}
{{ define "body" }} {{ define "body" }}
<h1>Home</h1> <main class="letters-home">
<p>{{ .Message }}</p> <section class="letters-intro">
<h1>Lenz-Briefe</h1>
<p>Digitale Edition der Briefe von Jakob Michael Reinhold Lenz</p>
</section>
<nav class="letters-nav" aria-label="Jahrgruppen">
<a class="letters-nav-item {{ if .ShowAll }}is-active{{ end }}" href="/briefe?range=all">Alle</a>
{{ range .Ranges }}
{{ if .Letters }}
<a class="letters-nav-item {{ if eq $.SelectedRange .Label }}is-active{{ end }}" href="/briefe?range={{ .Label }}">{{ .Label }}</a>
{{ end }}
{{ end }}
</nav>
<section class="letters-ranges">
{{ range .ActiveRanges }}
<div class="letters-range">
<h2>{{ .Label }}</h2>
<div class="letters-count">({{ len .Letters }} {{ if eq (len .Letters) 1 }}Brief{{ else }}Briefe{{ end }})</div>
{{ template "letterlist" .Letters }}
</div>
{{ end }}
</section>
</main>
{{ end }} {{ end }}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -1,18 +1,100 @@
@import "tailwindcss"; @import "tailwindcss";
@font-face {
font-family: "Linux Libertine";
font-style: normal;
font-weight: 400;
src: url("/public/fonts/LinLibertine_R_G.ttf") format("truetype");
}
@font-face {
font-family: "Linux Libertine";
font-style: italic;
font-weight: 400;
src: url("/public/fonts/LinLibertine_RI_G.ttf") format("truetype");
}
@font-face {
font-family: "Linux Libertine";
font-style: normal;
font-weight: 700;
src: url("/public/fonts/LinLibertine_RB_G.ttf") format("truetype");
}
@font-face {
font-family: "Linux Libertine";
font-style: italic;
font-weight: 700;
src: url("/public/fonts/LinLibertine_RBI_G.ttf") format("truetype");
}
@font-face {
font-family: "Linux Biolinum";
font-style: normal;
font-weight: 400;
src: url("/public/fonts/LinBiolinum_R_G.ttf") format("truetype");
}
@font-face {
font-family: "Linux Biolinum";
font-style: italic;
font-weight: 400;
src: url("/public/fonts/LinBiolinum_RI_G.ttf") format("truetype");
}
@font-face {
font-family: "Linux Biolinum";
font-style: normal;
font-weight: 700;
src: url("/public/fonts/LinBiolinum_RB_G.ttf") format("truetype");
}
@font-face {
font-family: "Playfair";
font-style: normal;
font-weight: 400;
src: url("/public/fonts/PlayfairDisplay-Regular.ttf") format("truetype");
}
@font-face {
font-family: "Playfair";
font-style: italic;
font-weight: 400;
src: url("/public/fonts/PlayfairDisplay-Italic.ttf") format("truetype");
}
@font-face {
font-family: "Playfair";
font-style: normal;
font-weight: 700;
src: url("/public/fonts/PlayfairDisplay-Bold.ttf") format("truetype");
}
@font-face {
font-family: "Playfair";
font-style: italic;
font-weight: 700;
src: url("/public/fonts/PlayfairDisplay-BoldItalic.ttf") format("truetype");
}
:root { :root {
--bg: #fff; --bg: #fff;
--ink: #2a2824; --ink: #2a2824;
--muted: #7f7a72; --muted: #7f7a72;
--rule: #d8d4cc; --rule: #d8d4cc;
--font-serif: "Linux Libertine", ui-serif, serif;
--font-sans: "Linux Biolinum", "Merriweather Sans", ui-sans-serif, sans-serif;
--font-sansugly: Arial, "Linux Biolinum", "Merriweather Sans", ui-sans-serif, sans-serif;
--font-didone: "Playfair", ui-serif, serif;
} }
body { body {
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
color: var(--ink); color: var(--ink);
font-family: Georgia, "Times New Roman", serif; font-family: var(--font-serif);
font-size: 1.125rem; font-size: 1.125rem;
padding: 1rem;
} }
html { html {
@@ -39,6 +121,20 @@ html {
background: #fff; background: #fff;
} }
.brief-head-letter {
align-items: flex-end;
border-bottom: 1px solid #d7d2c8;
margin-bottom: 0.8rem;
}
.brief-head-left {
flex: 1 1 auto;
}
.brief-nav-mid {
font-variant: small-caps;
}
.brief-head h1 { .brief-head h1 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
@@ -54,11 +150,9 @@ html {
.brief-nav-btn { .brief-nav-btn {
text-decoration: none; text-decoration: none;
border: 1px solid var(--rule);
border-radius: 999px;
color: var(--ink); color: var(--ink);
font-size: 0.86rem; font-size: 0.86rem;
padding: 0.2rem 0.5rem; padding: 0;
} }
.brief-nav-btn.is-disabled { opacity: 0.35; pointer-events: none; } .brief-nav-btn.is-disabled { opacity: 0.35; pointer-events: none; }
@@ -141,7 +235,7 @@ html {
padding: 0; padding: 0;
font-size: 0.78rem; font-size: 0.78rem;
color: #8d867b; color: #8d867b;
font-family: "Times New Roman", Georgia, serif; font-family: var(--font-serif);
font-style: italic; font-style: italic;
} }
@@ -188,49 +282,150 @@ html {
.line-indent-11 { text-indent: 6.38rem; } .line-indent-11 { text-indent: 6.38rem; }
.line-indent-12 { text-indent: 6.96rem; } .line-indent-12 { text-indent: 6.96rem; }
.line-align-ctx { text-align: right; } .line-align-ctx {
.line-align-ctx .tag-align.align-center { display: inline-block; width: 100%; text-align: center; } display: grid;
.line-align-ctx .tag-align.align-right { display: inline-block; width: 100%; text-align: right; } grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
align-items: baseline;
overflow: visible;
}
.line-tab-ctx .tag-tab { .line-align-ctx > * {
grid-column: 1;
justify-self: start;
}
.line-align-ctx .tag-align.align-left {
grid-column: 1;
text-align: left;
}
.line-align-ctx .tag-align.align-center,
.line .tag-align.align-center {
grid-column: 2;
text-align: center;
}
.line-align-ctx .tag-align.align-right,
.line .tag-align.align-right {
grid-column: 3;
text-align: right;
}
.line-tab-ctx .tag-tab,
.line-indent .tag-tab,
.line .tag-tab {
display: inline-block; display: inline-block;
min-width: 3.6ch; min-width: 3.6ch;
border-left: 1px dotted #cdbfae; border-left: 1px dotted #cdbfae;
padding-left: 0.34rem; padding-left: 0.34rem;
} }
.line-tab-ctx .tag-tab[data-tab="1"],
.line-indent .tag-tab[data-tab="1"],
.line .tag-tab[data-tab="1"] {
min-width: 5ch;
}
.line .tag-tab[data-tab="2"] { min-width: 8ch; }
.line .tag-tab[data-tab="3"] { min-width: 11ch; }
.line .tag-tab[data-tab="4"] { min-width: 14ch; }
.line .tag-tab[data-tab="5"] { min-width: 17ch; }
.line .tag-tab[data-tab="6"] { min-width: 20ch; }
.line .tag-tab[data-tab="7"] { min-width: 23ch; }
.line .tag-tab[data-tab="8"] { min-width: 26ch; }
.tag-aq, .tag-aq,
.tag-note { font-family: "Trebuchet MS", "Helvetica Neue", Arial, sans-serif; } .tag-note { font-family: var(--font-sansugly); }
.tag-b { font-weight: 700; } .tag-b { font-weight: 700; }
.tag-del { text-decoration: line-through; } .tag-del {
text-decoration: line-through;
position: relative;
}
.tag-dul { text-decoration: underline; text-decoration-style: double; } .tag-dul { text-decoration: underline; text-decoration-style: double; }
.tag-tul { text-decoration: underline; text-decoration-style: wavy; text-decoration-thickness: 2px; } .tag-tul { text-decoration: underline; text-decoration-style: wavy; text-decoration-thickness: 2px; }
.tag-it { font-style: italic; } .tag-it { font-style: italic; }
.tag-ul { text-decoration: underline; } .tag-ul { text-decoration: underline; }
.tag-note { font-size: 0.9em; font-weight: 700; color: var(--muted); } .tag-note { font-size: 0.9em; font-weight: 700; color: var(--muted); }
.tag-note { .tag-note {
font-family: "Times New Roman", Georgia, serif; font-family: var(--font-serif);
font-style: italic; font-style: italic;
font-weight: 400; font-weight: 400;
color: #8d867b; color: #8d867b;
} }
.tag-ink { color: #2d5e8a; } .tag-ink { color: #1e3a8a; }
.tag-pe { color: #4e667f; } .tag-pe { color: #57534e; }
.tag-tl { background: #f5e4d6; color: #8c5a41; padding: 0 0.12rem; border-radius: 2px; } .tag-tl {
background: #f5e4d6;
color: #8c5a41;
padding: 0 0.12rem;
border-radius: 2px;
}
.tag-tl::before {
content: "◌";
color: #475569;
font-family: var(--font-sans);
margin-right: 0.08rem;
}
.tag-nr { color: #6f675d; } .tag-nr { color: #6f675d; }
.tag-subst,
.tag-ru,
.tag-gr,
.tag-hb,
.tag-fn,
.tag-generic { display: inline; }
.tag-hand {
display: inline;
font-family: var(--font-didone);
font-size: 0.9em;
color: #000027;
}
.tag-er {
--tag-er-rgb: 0, 0, 39;
background-image: repeating-linear-gradient(
-45deg,
rgba(var(--tag-er-rgb), 0.5),
transparent 1px,
transparent 6px
);
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
color: transparent;
text-shadow: 0 0 rgb(var(--tag-er-rgb));
}
.tag-align.align-right {
display: block;
text-align: right;
}
.tag-align.align-center {
display: block;
text-align: center;
}
.line .tag-align,
.line .tag-align * {
text-indent: 0 !important;
}
.tag-insertion { .tag-insertion {
position: relative; position: relative;
padding-left: 0.1rem; white-space: nowrap;
} }
.tag-insertion::before { .tag-insertion::before {
content: "\2038"; content: "";
font-size: 0.78em; color: #475569;
vertical-align: super; margin-right: -0.2em;
color: #9a8b76; }
margin-right: 0.06rem;
.tag-insertion::after {
content: "⌟";
color: #475569;
margin-left: -0.4ch;
} }
@media (max-width: 960px) { @media (max-width: 960px) {
@@ -247,3 +442,102 @@ html {
text-align: left; text-align: left;
} }
} }
.letters-home {
max-width: 1024px;
margin: 0 auto;
}
.letters-nav {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 1.2rem;
}
.letters-nav-item {
display: inline-block;
text-decoration: none;
padding: 0.05rem 0.1rem;
font-size: 0.85rem;
color: #5f584f;
}
.letters-nav-item:hover {
color: #2a2824;
}
.letters-nav-item.is-active {
color: #2a2824;
text-decoration: underline;
text-underline-offset: 0.18rem;
}
.letters-intro h1 {
font-size: 1.9rem;
margin-bottom: 0.2rem;
}
.letters-intro p {
color: #6e675f;
margin-bottom: 1.5rem;
}
.letters-range {
padding-left: 0;
margin-bottom: 1.8rem;
}
.letters-range h2 {
font-size: 1.25rem;
}
.letters-count {
font-size: 0.95rem;
color: #6e675f;
margin: 0.25rem 0 0.8rem;
}
.letter-list-item {
display: block;
margin-bottom: 0.75rem;
padding: 0.15rem 0;
}
.letter-list-item:hover {
background: transparent;
}
.letterhead-date {
font-style: italic;
}
.letterhead-meta {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.letterhead-badges {
display: inline-flex;
gap: 0.35rem;
}
.letter-badge {
padding: 0;
font-size: 0.72rem;
color: #6e675f;
}
.letter-badge i {
vertical-align: middle;
}
.letterhead-row {
display: flex;
gap: 0.65rem;
}
.letterhead-arrow {
color: #8b847a;
}