diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-29 14:24:16 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-29 14:24:16 +0200 |
| commit | 855859bdaac101e7e87d5cb0237b7c30f4f4b371 (patch) | |
| tree | e0d4af3c9082c4b7ceba2d05471c6a162cfe2940 /internal | |
| parent | 18578434f41d4fe34438c2f171388109a288c18c (diff) | |
| download | lectio-855859bdaac101e7e87d5cb0237b7c30f4f4b371.tar.gz lectio-855859bdaac101e7e87d5cb0237b7c30f4f4b371.zip | |
web: declutter the UI, fix date arrows, red * for bookmarked verses in reader
Daily page (index.html):
- Date arrows now step the date input client-side and dispatch its change, so
they move relative to the CURRENT day and update the shown date (they were
stuck one step from the initial date, computed server-side).
- Theme picker removed (theme lives only in /settings now); bookmarks link
removed (bookmarks belong to the reader, not the daily readings).
- reader/settings nav + the download links move to a right-aligned .controls-side
cluster; essential controls (date, lectionary, versions, parts, layout, mono)
stay on the left. mono stops its change bubbling so it no longer refetches.
Reader (reader.html + RenderPassage): a bookmarked verse now shows a red "*"
(end of line in columns, after the verse number interlinear). Threaded a
marked set (keyed "chap:verse") through the reader render path only; the daily
RenderReadings path is untouched.
Settings (settings.html): Save button moved to the top; removed offline (dead),
width and pager (CLI/TUI-only, no web effect) -- their config values are
preserved since the handler no longer reads/zeroes them.
Footer: source/licence centred.
Web tests + full suite green both build modes.
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/web/render.go | 26 | ||||
| -rw-r--r-- | internal/web/render_test.go | 6 | ||||
| -rw-r--r-- | internal/web/server.go | 22 | ||||
| -rw-r--r-- | internal/web/static/base.css | 15 | ||||
| -rw-r--r-- | internal/web/templates/index.html | 76 | ||||
| -rw-r--r-- | internal/web/templates/reader.html | 9 | ||||
| -rw-r--r-- | internal/web/templates/readings-interlinear.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/readings-vertical.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/readings.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/settings.html | 15 |
10 files changed, 93 insertions, 82 deletions
diff --git a/internal/web/render.go b/internal/web/render.go index 3ea52e3..b7fb3ed 100644 --- a/internal/web/render.go +++ b/internal/web/render.go @@ -59,6 +59,7 @@ type columnView struct { type blockView struct { VNum, Text string Refrain bool + Marked bool // the reader flags a bookmarked verse with a red "*" } // ilSectionView, ilVerseView and ilLineView are what @@ -74,8 +75,9 @@ type ilSectionView struct { } type ilVerseView struct { - VNum string - Lines []ilLineView + VNum string + Lines []ilLineView + Marked bool // the reader flags a bookmarked verse with a red "*" } type ilLineView struct { @@ -314,19 +316,21 @@ func UnionChapters(canonical string) []int { // name in the sigla dialect (the section heading is "<name> <chap>"). versions // are corpus versions; one lacking the chapter shows a localized "(not in X)" // note (columns) or is skipped (interlinear). lang localizes the version labels. -func RenderPassage(canonical, name string, chap int, versions []string, display, lang string) template.HTML { +// marked (keyed by "chap:verse") flags verses that carry a bookmark, so the +// reader can show a red "*"; pass nil for no marks. +func RenderPassage(canonical, name string, chap int, versions []string, display, lang string, marked map[string]bool) template.HTML { heading := fmt.Sprintf("%s %d", name, chap) switch display { case "interlinear": - return renderTemplate("readings-interlinear.html", passageInterlinear(canonical, heading, chap, versions, lang)) + return renderTemplate("readings-interlinear.html", passageInterlinear(canonical, heading, chap, versions, lang, marked)) case "vertical": - return renderTemplate("readings-vertical.html", passageColumns(canonical, heading, chap, versions, lang)) + return renderTemplate("readings-vertical.html", passageColumns(canonical, heading, chap, versions, lang, marked)) default: - return renderTemplate("readings.html", passageColumns(canonical, heading, chap, versions, lang)) + return renderTemplate("readings.html", passageColumns(canonical, heading, chap, versions, lang, marked)) } } -func passageColumns(canonical, heading string, chap int, versions []string, lang string) []sectionView { +func passageColumns(canonical, heading string, chap int, versions []string, lang string, marked map[string]bool) []sectionView { cols := make([]columnView, 0, len(versions)) for _, v := range versions { verses := bible.Verses(v, canonical, chap) @@ -335,14 +339,15 @@ func passageColumns(canonical, heading string, chap int, versions []string, lang blocks = append(blocks, blockView{Text: fmt.Sprintf(i18n.Get(lang).NoVersion, v)}) } for _, ve := range verses { - blocks = append(blocks, blockView{VNum: fmt.Sprintf("%d:%d", ve.Chapter, ve.Verse), Text: ve.Text}) + vnum := fmt.Sprintf("%d:%d", ve.Chapter, ve.Verse) + blocks = append(blocks, blockView{VNum: vnum, Text: ve.Text, Marked: marked[vnum]}) } cols = append(cols, columnView{Label: webVersionLabel(v, lang), Blocks: blocks}) } return []sectionView{{Heading: heading, PartID: "reader", Columns: cols}} } -func passageInterlinear(canonical, heading string, chap int, versions []string, lang string) []ilSectionView { +func passageInterlinear(canonical, heading string, chap int, versions []string, lang string, marked map[string]bool) []ilSectionView { var sets []verseSet for _, v := range versions { verses := bible.Verses(v, canonical, chap) @@ -357,6 +362,9 @@ func passageInterlinear(canonical, heading string, chap int, versions []string, return []ilSectionView{view} } view.Verses = interleaveVerses(sets) + for i := range view.Verses { + view.Verses[i].Marked = marked[view.Verses[i].VNum] + } return []ilSectionView{view} } diff --git a/internal/web/render_test.go b/internal/web/render_test.go index 94d5e6e..4cd1301 100644 --- a/internal/web/render_test.go +++ b/internal/web/render_test.go @@ -200,7 +200,7 @@ func TestRenderReadingsLocalizesEN(t *testing.T) { } func TestRenderPassageColumns(t *testing.T) { - html := string(RenderPassage("John", "John", 3, []string{"wuj"}, "vertical", "en")) + html := string(RenderPassage("John", "John", 3, []string{"wuj"}, "vertical", "en", nil)) if !strings.Contains(html, "John 3") { // passage heading, independent of the corpus t.Errorf("passage columns missing heading:\n%s", html) } @@ -213,7 +213,7 @@ func TestRenderPassageColumns(t *testing.T) { } func TestRenderPassageInterlinear(t *testing.T) { - html := string(RenderPassage("John", "John", 3, []string{"wuj", "vul"}, "interlinear", "en")) + html := string(RenderPassage("John", "John", 3, []string{"wuj", "vul"}, "interlinear", "en", nil)) if !strings.Contains(html, "3:16") { t.Errorf("interlinear missing verse:\n%s", html) } @@ -222,7 +222,7 @@ func TestRenderPassageInterlinear(t *testing.T) { func TestRenderPassageMissingVersion(t *testing.T) { // A non-existent chapter yields no verses: the column must carry the // "(not in wuj)" note, never a panic or an empty column. - html := string(RenderPassage("John", "John", 999, []string{"wuj"}, "vertical", "en")) + html := string(RenderPassage("John", "John", 999, []string{"wuj"}, "vertical", "en", nil)) if !strings.Contains(html, "wuj") { t.Errorf("missing-passage note absent:\n%s", html) } diff --git a/internal/web/server.go b/internal/web/server.go index 92cb758..7d9dfe5 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -72,7 +72,7 @@ func NewServer(cfg config.Config) http.Handler { mux.HandleFunc("GET /calendar", func(w http.ResponseWriter, r *http.Request) { calendarHandler(s.get())(w, r) }) mux.HandleFunc("GET /api/calendar.json", func(w http.ResponseWriter, r *http.Request) { apiCalendarJSONHandler(s.get())(w, r) }) mux.HandleFunc("GET /calendar.ics", func(w http.ResponseWriter, r *http.Request) { calendarICSHandler(s.get())(w, r) }) - mux.HandleFunc("GET /reader", func(w http.ResponseWriter, r *http.Request) { readerHandler(s.get(), s.table())(w, r) }) + mux.HandleFunc("GET /reader", func(w http.ResponseWriter, r *http.Request) { readerHandler(s.get(), s.table(), s.bm)(w, r) }) mux.HandleFunc("GET /theme.css", func(w http.ResponseWriter, r *http.Request) { themeCSSHandler(s.get())(w, r) }) mux.HandleFunc("GET /source", sourceHandler) mux.HandleFunc("GET /settings", settingsGet(s)) @@ -500,7 +500,7 @@ type chapOpt struct { // navigation and corpus-version compare, rendering the same reading-pane // templates/themes as the daily view. The controls form re-fetches /reader and // hx-selects #reader-root, so book/chapter/version selects stay in sync. -func readerHandler(cfg config.Config, tbl *bible.BookTable) http.HandlerFunc { +func readerHandler(cfg config.Config, tbl *bible.BookTable, bm *bookmarks.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { books := tbl.Books(cfg.SiglaLang()) if len(books) == 0 { // defensive: embedded table always has books @@ -525,7 +525,18 @@ func readerHandler(cfg config.Config, tbl *bible.BookTable) http.HandlerFunc { theme = cfg.WebTheme } - reading := RenderPassage(info.Canonical, info.Name, chap, versions, display, cfg.UILanguage) + // Verses in this book+chapter that carry a bookmark get a red "*". + marked := map[string]bool{} + if bm != nil { + if all, err := bm.List(""); err == nil { + for _, b := range all { + if b.Book == info.Canonical && b.Chapter == chap { + marked[fmt.Sprintf("%d:%d", b.Chapter, b.Verse)] = true + } + } + } + } + reading := RenderPassage(info.Canonical, info.Name, chap, versions, display, cfg.UILanguage, marked) bookOpts := make([]bookOpt, 0, len(books)) for _, b := range books { @@ -807,11 +818,10 @@ func settingsPost(s *server) http.HandlerFunc { cfg.Versions = r.PostForm["versions"] cfg.WebVersions = r.PostForm["web_versions"] cfg.All = r.PostForm.Get("all") != "" - cfg.Offline = r.PostForm.Get("offline") != "" cfg.WebMono = r.PostForm.Get("web_mono") != "" - cfg.Width = atoiOr(r.PostForm.Get("width"), cfg.Width) cfg.WebPort = atoiOr(r.PostForm.Get("web_port"), cfg.WebPort) - cfg.Pager = r.PostForm.Get("pager") + // offline, width and pager are not web-editable; their config values are + // preserved (the form no longer carries those fields). booksText := r.PostForm.Get("books") diff --git a/internal/web/static/base.css b/internal/web/static/base.css index d367c15..2faabc5 100644 --- a/internal/web/static/base.css +++ b/internal/web/static/base.css @@ -293,10 +293,24 @@ a { .bookmark .tags { font-size: 0.85rem; } .tag-filter { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-bottom: var(--space-2); font-family: var(--font-ui); } +/* Utility cluster (nav links + downloads), pushed to the right of the controls + * bar; wraps beneath when the row is narrow. */ +.controls-side { + margin-left: auto; + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + /* Export links (download the readings). */ .export { font-family: var(--font-ui); font-size: 0.85rem; } .export a { margin: 0 0.15rem; } +/* Bookmark marker: a red "*" flagging a bookmarked verse in the reader. A fixed + * semantic red (not a theme colour), visible on light and dark themes alike. */ +.vmark { color: #e5484d; font-weight: bold; padding-left: 0.15rem; } + /* Licence footer: the AGPL-3.0 §13 source offer, on every full page. * Quiet by design but never hidden -- §13 asks for a PROMINENT offer, so * this must not be display:none'd or themed to invisibility. Colour (border @@ -309,4 +323,5 @@ a { border-top-style: solid; font-family: var(--font-ui); font-size: 0.75rem; + text-align: center; } diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index b99725d..c706323 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -1,15 +1,12 @@ -{{/* index.html renders the full lectio-web page: top-bar controls (date, - lectionary, version checkboxes, all/gospel toggle, display select, - theme select) and the pre-rendered reading pane (#pane). +{{/* index.html renders the full lectio-web page: essential controls on the left + (date, lectionary, version checkboxes, all/gospel, layout, mono) and, pushed + to the right, the nav + download links. Theme lives only in /settings now. - The controls form (#controls) carries hx-get="/readings" and fires on - any "change" bubbling up from its fields (htmx auto-includes an - hx-get element's enclosing form fields as the request's query - params -- no hx-include needed), so "display" needs no extra wiring - beyond living inside #controls. The date +/- buttons are separate - hx-get elements nested in the same form so their own field values are - included too; they override "date" via hx-vals. The theme <select> - has no server round trip: it swaps the #theme <link>'s href directly. */}} + The controls form (#controls) carries hx-get="/readings" and fires on any + bubbling "change", so the readings pane re-fetches when a field changes. + The date arrows step the date input by a day client-side and dispatch its + change (so navigation is relative to the CURRENT date and updates the shown + day). mono lives OUTSIDE the form so toggling it doesn't refetch readings. */}} <!doctype html> <html lang="{{.Lang}}"> <head> @@ -19,6 +16,16 @@ <link rel="stylesheet" href="/static/base.css"> <link id="theme" rel="stylesheet" href="/theme.css?name={{.Theme}}"> <script src="/static/htmx.min.js"></script> +<script> +function stepDate(n){ + var el=document.querySelector('#controls input[name=date]'); + if(!el||!el.value)return; + var d=new Date(el.value+'T00:00:00'); + d.setDate(d.getDate()+n); + el.value=d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); + el.dispatchEvent(new Event('change',{bubbles:true})); +} +</script> </head> <body{{if .Mono}} class="mono"{{end}}> <div class="page"> @@ -28,11 +35,9 @@ version unchecked (show nothing) from a fresh visit (config default). */}} <input type="hidden" name="vset" value="1"> <span class="date-nav"> - <button type="button" hx-get="/readings" hx-target="#pane" - hx-vals='{"date":"{{.PrevDate}}"}'>←</button> + <button type="button" onclick="stepDate(-1)">←</button> <input type="date" name="date" value="{{.Date}}"> - <button type="button" hx-get="/readings" hx-target="#pane" - hx-vals='{"date":"{{.NextDate}}"}'>→</button> + <button type="button" onclick="stepDate(1)">→</button> </span> <label>{{.L.Lectionary}} @@ -61,31 +66,24 @@ </select> </label> - <a class="nav-link" href="/reader">{{.L.NavReader}} →</a> - <a class="nav-link" href="/settings">{{.L.NavSettings}}</a> - <a class="nav-link" href="/bookmarks">{{.L.NavBookmarks}}</a> - </form> - - <label class="theme-picker">{{.L.Theme}} - <select id="theme-select" - onchange="var o=document.getElementById('theme'),n=o.cloneNode(false);n.setAttribute('href','/theme.css?name='+encodeURIComponent(this.value));o.replaceWith(n);"> - {{range .ThemeOpts}} - <option value="{{.Name}}" {{if .Selected}}selected{{end}}>{{.Name}}</option> - {{end}} - </select> - </label> - - <label class="mono-toggle"><input type="checkbox" {{if .Mono}}checked{{end}} - onchange="document.body.classList.toggle('mono', this.checked)"> {{.L.Mono}}</label> + {{/* mono lives in the form but stops its change from bubbling, so toggling + it flips the body class without refetching the readings. */}} + <label class="mono-toggle"><input type="checkbox" {{if .Mono}}checked{{end}} + onchange="event.stopPropagation();document.body.classList.toggle('mono', this.checked)"> {{.L.Mono}}</label> - {{/* Download the current readings; the URL is built from the live control - values at click time so it always matches what's on screen. */}} - <span class="export">{{.L.Export}}: - <a href="#" onclick="location='/export?fmt=txt&'+new URLSearchParams(new FormData(document.getElementById('controls'))).toString();return false;">txt</a> - <a href="#" onclick="location='/export?fmt=md&'+new URLSearchParams(new FormData(document.getElementById('controls'))).toString();return false;">md</a> - <a href="#" onclick="location='/export?fmt=pdf&'+new URLSearchParams(new FormData(document.getElementById('controls'))).toString();return false;">pdf</a> - · <a href="#" onclick="var d=(document.querySelector('#controls [name=date]').value||'').slice(0,7);location='/calendar?month='+d+'&lectionary='+document.querySelector('#controls [name=lectionary]').value;return false;">{{.L.Calendar}}</a> - </span> + {{/* Utility links, pushed to the right (see .controls-side). The export URLs + are built from the live control values at click time. */}} + <span class="controls-side"> + <a class="nav-link" href="/reader">{{.L.NavReader}} →</a> + <a class="nav-link" href="/settings">{{.L.NavSettings}}</a> + <span class="export">{{.L.Export}}: + <a href="#" onclick="location='/export?fmt=txt&'+new URLSearchParams(new FormData(document.getElementById('controls'))).toString();return false;">txt</a> + <a href="#" onclick="location='/export?fmt=md&'+new URLSearchParams(new FormData(document.getElementById('controls'))).toString();return false;">md</a> + <a href="#" onclick="location='/export?fmt=pdf&'+new URLSearchParams(new FormData(document.getElementById('controls'))).toString();return false;">pdf</a> + · <a href="#" onclick="var d=(document.querySelector('#controls [name=date]').value||'').slice(0,7);location='/calendar?month='+d+'&lectionary='+document.querySelector('#controls [name=lectionary]').value;return false;">{{.L.Calendar}}</a> + </span> + </span> + </form> <div id="pane">{{.Reading}}</div> diff --git a/internal/web/templates/reader.html b/internal/web/templates/reader.html index 36cc8a1..9fa857d 100644 --- a/internal/web/templates/reader.html +++ b/internal/web/templates/reader.html @@ -70,15 +70,6 @@ <div id="pane">{{.Reading}}</div> </div> - <label class="theme-picker">{{.L.Theme}} - <select id="theme-select" - onchange="var o=document.getElementById('theme'),n=o.cloneNode(false);n.setAttribute('href','/theme.css?name='+encodeURIComponent(this.value));o.replaceWith(n);"> - {{range .ThemeOpts}} - <option value="{{.Name}}" {{if .Selected}}selected{{end}}>{{.Name}}</option> - {{end}} - </select> - </label> - <label class="mono-toggle"><input type="checkbox" {{if .Mono}}checked{{end}} onchange="document.body.classList.toggle('mono', this.checked)"> {{.L.Mono}}</label> diff --git a/internal/web/templates/readings-interlinear.html b/internal/web/templates/readings-interlinear.html index 5b71318..89ad656 100644 --- a/internal/web/templates/readings-interlinear.html +++ b/internal/web/templates/readings-interlinear.html @@ -16,7 +16,7 @@ <div class="display-interlinear"> {{range .Verses}} <div class="ilverse"> - <span class="vnum">{{.VNum}}</span> + <span class="vnum">{{.VNum}}</span>{{if .Marked}}<span class="vmark">*</span>{{end}} {{range .Lines}} <div class="illine"><span class="version-label">{{.Label}}</span> <span>{{.Text}}</span></div> {{end}} diff --git a/internal/web/templates/readings-vertical.html b/internal/web/templates/readings-vertical.html index d9896c1..72327d7 100644 --- a/internal/web/templates/readings-vertical.html +++ b/internal/web/templates/readings-vertical.html @@ -16,7 +16,7 @@ {{if .Refrain}} <p class="block refrain">{{.Text}}</p> {{else if .VNum}} - <p class="block verse"><span class="vnum">{{.VNum}}</span><span class="vtext">{{.Text}}</span></p> + <p class="block verse"><span class="vnum">{{.VNum}}</span><span class="vtext">{{.Text}}</span>{{if .Marked}}<span class="vmark">*</span>{{end}}</p> {{else}} <p class="block">{{.Text}}</p> {{end}} diff --git a/internal/web/templates/readings.html b/internal/web/templates/readings.html index a1cbcdb..b760892 100644 --- a/internal/web/templates/readings.html +++ b/internal/web/templates/readings.html @@ -13,7 +13,7 @@ {{if .Refrain}} <p class="block refrain">{{.Text}}</p> {{else if .VNum}} - <p class="block verse"><span class="vnum">{{.VNum}}</span><span class="vtext">{{.Text}}</span></p> + <p class="block verse"><span class="vnum">{{.VNum}}</span><span class="vtext">{{.Text}}</span>{{if .Marked}}<span class="vmark">*</span>{{end}}</p> {{else}} <p class="block">{{.Text}}</p> {{end}} diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index 3ae2f41..8e18278 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -23,6 +23,8 @@ <form class="settings" method="post" action="/settings"> + <div class="row"><button type="submit">{{.L.WebSave}}</button></div> + <label>{{.L.Lectionary}} <select name="lectionary"> <option value="new" {{if eq .Cfg.Lectionary "new"}}selected{{end}}>{{.L.OptModern}}</option> @@ -77,30 +79,17 @@ <div class="row"> <label><input type="checkbox" name="all" {{if .Cfg.All}}checked{{end}}> {{.L.OptAll}}</label> - <label><input type="checkbox" name="offline" {{if .Cfg.Offline}}checked{{end}}> {{.L.WebOffline}}</label> <label><input type="checkbox" name="web_mono" {{if .Cfg.WebMono}}checked{{end}}> {{.L.Mono}}</label> </div> - <label>{{.L.WebWidth}} - <input type="number" name="width" value="{{.Cfg.Width}}"> - </label> - <label>{{.L.WebPort}} <input type="number" name="web_port" value="{{.Cfg.WebPort}}"> </label> - <label>{{.L.WebPager}} - <input type="text" name="pager" value="{{.Cfg.Pager}}"> - </label> - <label>books.ini <textarea name="books" rows="20">{{.Books}}</textarea> </label> - <div class="row"> - <button type="submit">{{.L.WebSave}}</button> - </div> - </form> </div> |
