aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/i18n/i18n.go9
-rw-r--r--internal/web/render.go160
-rw-r--r--internal/web/render_test.go23
-rw-r--r--internal/web/server.go168
-rw-r--r--internal/web/server_test.go51
-rw-r--r--internal/web/static/base.css12
-rw-r--r--internal/web/templates/index.html2
-rw-r--r--internal/web/templates/reader.html72
9 files changed, 463 insertions, 36 deletions
diff --git a/internal/config/config.go b/internal/config/config.go
index 9a41adc..b17482b 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -24,7 +24,7 @@ var seedTOML []byte
// Version is lectio's release version, shared by every binary's
// -v/--version output (lectio, lectio-ui, lectio-web).
-const Version = "0.8.0"
+const Version = "0.9.0"
// validVersions are the five scripture versions lectio understands.
var validVersions = map[string]bool{
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
index f752e9b..d9443ea 100644
--- a/internal/i18n/i18n.go
+++ b/internal/i18n/i18n.go
@@ -42,6 +42,9 @@ type UI struct {
// requested version could be interleaved for a section.
NoInterlinearVerses string
+ // Web reader (lectio-web /reader) control labels.
+ NavReader, WebBook, WebChapter string
+
// NoReadingsDay is the web error fragment shown when readings.Load
// returns no sections at all for the requested date.
NoReadingsDay string
@@ -106,6 +109,9 @@ var enUI = UI{
Theme: "theme",
Mono: "mono",
NoInterlinearVerses: "(no verses to align)",
+ NavReader: "reader",
+ WebBook: "book",
+ WebChapter: "chapter",
NoReadingsDay: "no readings for this day",
NoVersion: "(not in %s)",
NoVersionPartial: "(not in %s: %s)",
@@ -154,6 +160,9 @@ var plUI = UI{
Theme: "motyw",
Mono: "mono",
NoInterlinearVerses: "(brak wersetów do zestawienia interlinearnego)",
+ NavReader: "czytnik",
+ WebBook: "księga",
+ WebChapter: "rozdział",
NoReadingsDay: "brak czytań na ten dzień",
NoVersion: "(brak w „%s”)",
NoVersionPartial: "(brak w „%s”: %s)",
diff --git a/internal/web/render.go b/internal/web/render.go
index 9e6280c..35c30b9 100644
--- a/internal/web/render.go
+++ b/internal/web/render.go
@@ -201,6 +201,46 @@ func interlinearVersions(versions []string) []string {
return render.OfflineVersions(versions)
}
+// verseSet is one version's verses plus its column label, the unit both the
+// interlinear readings view and the reader passage view interleave over.
+type verseSet struct {
+ label string
+ verses []bible.Verse
+}
+
+// interleaveVerses interleaves several versions' verses by chapter:verse: the
+// ordered union of keys follows the first set's verse order, then any key only
+// a later set has is appended in that set's order (stable, no duplicates).
+func interleaveVerses(sets []verseSet) []ilVerseView {
+ type vkey struct{ chapter, verse int }
+ order := make([]vkey, 0)
+ seen := map[vkey]bool{}
+ bySet := make([]map[vkey]bible.Verse, len(sets))
+ for i, s := range sets {
+ m := make(map[vkey]bible.Verse, len(s.verses))
+ for _, v := range s.verses {
+ k := vkey{v.Chapter, v.Verse}
+ m[k] = v
+ if !seen[k] {
+ seen[k] = true
+ order = append(order, k)
+ }
+ }
+ bySet[i] = m
+ }
+ vviews := make([]ilVerseView, 0, len(order))
+ for _, k := range order {
+ var lines []ilLineView
+ for i, s := range sets {
+ if v, ok := bySet[i][k]; ok {
+ lines = append(lines, ilLineView{Label: s.label, Text: v.Text})
+ }
+ }
+ vviews = append(vviews, ilVerseView{VNum: fmt.Sprintf("%d:%d", k.chapter, k.verse), Lines: lines})
+ }
+ return vviews
+}
+
// buildInterlinearViews gathers each requested version's verses via
// render.GatherVerses (after the bt->wuj substitution, see
// interlinearVersions) and interleaves them by chapter:verse: the ordered
@@ -214,11 +254,6 @@ func interlinearVersions(versions []string) []string {
func buildInterlinearViews(secs []liturgy.Section, versions []string, lectionary, lang string) []ilSectionView {
mapped := interlinearVersions(versions)
- type verseSet struct {
- label string
- verses []bible.Verse
- }
-
views := make([]ilSectionView, 0, len(secs))
for _, sec := range secs {
view := ilSectionView{Heading: render.LocalizeHeading(sec.Heading, sec.PartID, lang), Subtitle: sec.Subtitle, PartID: sec.PartID}
@@ -238,40 +273,95 @@ func buildInterlinearViews(secs []liturgy.Section, versions []string, lectionary
continue
}
- type vkey struct{ chapter, verse int }
- order := make([]vkey, 0)
- seen := map[vkey]bool{}
- bySet := make([]map[vkey]bible.Verse, len(sets))
- for i, s := range sets {
- m := make(map[vkey]bible.Verse, len(s.verses))
- for _, v := range s.verses {
- k := vkey{v.Chapter, v.Verse}
- m[k] = v
- if !seen[k] {
- seen[k] = true
- order = append(order, k)
- }
- }
- bySet[i] = m
+ view.Verses = interleaveVerses(sets)
+ views = append(views, view)
+ }
+ return views
+}
+
+// webVersionLabel is the localized column label for a version, falling back to
+// the bare code (mirrors render's unexported versionLabel, which web can't reach).
+func webVersionLabel(v, lang string) string {
+ if l, ok := i18n.Get(lang).Version[v]; ok {
+ return l
+ }
+ return v
+}
+
+// readerCorpusVersions are the versions the /reader offers: the four with an
+// embedded full-text corpus. "bt" (the niedziela.pl scrape) has no corpus and
+// cannot be read chapter-by-chapter.
+var readerCorpusVersions = []string{"wuj", "vul", "grb", "drb"}
+
+// UnionChapters returns the sorted union of chapter numbers a book has across
+// all corpus versions, so the reader's chapter navigation is stable regardless
+// of which versions are currently selected (a version lacking the book just
+// shows a note).
+func UnionChapters(canonical string) []int {
+ seen := map[int]bool{}
+ for _, v := range readerCorpusVersions {
+ for _, c := range bible.Chapters(v, canonical) {
+ seen[c] = true
}
+ }
+ chaps := make([]int, 0, len(seen))
+ for c := range seen {
+ chaps = append(chaps, c)
+ }
+ sort.Ints(chaps)
+ return chaps
+}
- vviews := make([]ilVerseView, 0, len(order))
- for _, k := range order {
- var lines []ilLineView
- for i, s := range sets {
- if v, ok := bySet[i][k]; ok {
- lines = append(lines, ilLineView{Label: s.label, Text: v.Text})
- }
- }
- vviews = append(vviews, ilVerseView{
- VNum: fmt.Sprintf("%d:%d", k.chapter, k.verse),
- Lines: lines,
- })
+// RenderPassage builds the reader pane for one book+chapter across versions, in
+// the same three layouts as RenderReadings (columns / vertical / interlinear),
+// reusing the very same templates and role classes. name is the book's display
+// 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 {
+ heading := fmt.Sprintf("%s %d", name, chap)
+ switch display {
+ case "interlinear":
+ return renderTemplate("readings-interlinear.html", passageInterlinear(canonical, heading, chap, versions, lang))
+ case "vertical":
+ return renderTemplate("readings-vertical.html", passageColumns(canonical, heading, chap, versions, lang))
+ default:
+ return renderTemplate("readings.html", passageColumns(canonical, heading, chap, versions, lang))
+ }
+}
+
+func passageColumns(canonical, heading string, chap int, versions []string, lang string) []sectionView {
+ cols := make([]columnView, 0, len(versions))
+ for _, v := range versions {
+ verses := bible.Verses(v, canonical, chap)
+ var blocks []blockView
+ if len(verses) == 0 {
+ blocks = append(blocks, blockView{Text: fmt.Sprintf(i18n.Get(lang).NoVersion, v)})
}
- view.Verses = vviews
- views = append(views, view)
+ for _, ve := range verses {
+ blocks = append(blocks, blockView{VNum: fmt.Sprintf("%d:%d", ve.Chapter, ve.Verse), Text: ve.Text})
+ }
+ cols = append(cols, columnView{Label: webVersionLabel(v, lang), Blocks: blocks})
}
- return views
+ return []sectionView{{Heading: heading, PartID: "reader", Columns: cols}}
+}
+
+func passageInterlinear(canonical, heading string, chap int, versions []string, lang string) []ilSectionView {
+ var sets []verseSet
+ for _, v := range versions {
+ verses := bible.Verses(v, canonical, chap)
+ if len(verses) == 0 {
+ continue
+ }
+ sets = append(sets, verseSet{label: webVersionLabel(v, lang), verses: verses})
+ }
+ view := ilSectionView{Heading: heading, PartID: "reader"}
+ if len(sets) == 0 {
+ view.Note = i18n.Get(lang).NoInterlinearVerses
+ return []ilSectionView{view}
+ }
+ view.Verses = interleaveVerses(sets)
+ return []ilSectionView{view}
}
// Themes returns the sorted, deduplicated union of the embedded theme
diff --git a/internal/web/render_test.go b/internal/web/render_test.go
index de3c7c1..6002927 100644
--- a/internal/web/render_test.go
+++ b/internal/web/render_test.go
@@ -182,6 +182,29 @@ func TestRenderReadingsLocalizesEN(t *testing.T) {
}
}
+func TestRenderPassageColumns(t *testing.T) {
+ html := string(RenderPassage("John", "John", 3, []string{"wuj"}, "vertical", "en"))
+ if !strings.Contains(html, "John 3") || !strings.Contains(html, "3:16") {
+ t.Errorf("passage columns missing heading/verse:\n%s", html)
+ }
+}
+
+func TestRenderPassageInterlinear(t *testing.T) {
+ html := string(RenderPassage("John", "John", 3, []string{"wuj", "vul"}, "interlinear", "en"))
+ if !strings.Contains(html, "3:16") {
+ t.Errorf("interlinear missing verse:\n%s", html)
+ }
+}
+
+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"))
+ if !strings.Contains(html, "wuj") {
+ t.Errorf("missing-passage note absent:\n%s", html)
+ }
+}
+
func TestUserTheme(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
diff --git a/internal/web/server.go b/internal/web/server.go
index d8b451a..667e986 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -15,9 +15,11 @@ import (
"os/exec"
"regexp"
"runtime"
+ "strconv"
"strings"
"time"
+ "github.com/lukaszkasprzak/lectio/internal/bible"
"github.com/lukaszkasprzak/lectio/internal/config"
"github.com/lukaszkasprzak/lectio/internal/i18n"
"github.com/lukaszkasprzak/lectio/internal/liturgy"
@@ -34,10 +36,13 @@ var bibleVersions = []string{"bt", "wuj", "vul", "grb", "drb"}
// (lectionary, versions, theme, offline) that requests can override via
// query parameters; it is never mutated.
func NewServer(cfg config.Config) http.Handler {
+ tbl, _ := bible.LoadBookTable(config.UserBooksTOML())
+
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", indexHandler(cfg))
mux.HandleFunc("GET /readings", readingsHandler(cfg))
+ mux.HandleFunc("GET /reader", readerHandler(cfg, tbl))
mux.HandleFunc("GET /theme.css", themeCSSHandler(cfg))
staticSub, err := fs.Sub(staticFS, "static")
@@ -282,6 +287,169 @@ func renderOrError(secs []liturgy.Section, versions []string, lectionary, displa
return RenderReadings(secs, versions, lectionary, display, lang, dayInfo)
}
+// requestReaderVersions returns the corpus versions the reader request asks for
+// (repeated ?v=), dropping "bt" (no corpus) and anything invalid; falling back
+// to the configured default (or "wuj" when that is bt/invalid) so a plain visit
+// shows exactly one column.
+func requestReaderVersions(cfg config.Config, r *http.Request) []string {
+ var out []string
+ for _, v := range r.URL.Query()["v"] {
+ if v != "bt" && config.ValidVersion(v) {
+ out = append(out, v)
+ }
+ }
+ if len(out) == 0 {
+ d := cfg.DefaultVersion
+ if d == "bt" || !config.ValidVersion(d) {
+ d = "wuj"
+ }
+ out = []string{d}
+ }
+ return out
+}
+
+// findBook returns the BookInfo whose Canonical matches, and whether found.
+func findBook(books []bible.BookInfo, canonical string) (bible.BookInfo, bool) {
+ for _, b := range books {
+ if b.Canonical == canonical {
+ return b, true
+ }
+ }
+ return bible.BookInfo{}, false
+}
+
+// clampChap keeps chap within the book's available chapters (contiguous in
+// practice); 0 when the book has none in any corpus version.
+func clampChap(chap int, chaps []int) int {
+ if len(chaps) == 0 {
+ return 0
+ }
+ if chap < chaps[0] {
+ return chaps[0]
+ }
+ last := chaps[len(chaps)-1]
+ if chap > last {
+ return last
+ }
+ return chap
+}
+
+// atoiDefault parses s as an int, returning def when it is empty/unparsable.
+func atoiDefault(s string, def int) int {
+ if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
+ return n
+ }
+ return def
+}
+
+type readerData struct {
+ BookOpts []bookOpt
+ Chap int
+ PrevChap, NextChap int
+ ChapOpts []chapOpt
+ VersionOpts []versionOpt
+ Display string
+ ThemeOpts []themeOpt
+ Theme string
+ Mono bool
+ Reading template.HTML
+ L i18n.UI
+ Lang string
+}
+
+type bookOpt struct {
+ Value string // canonical name (corpus/query value)
+ Label string // dialect display name
+ Selected bool
+}
+
+type chapOpt struct {
+ N int
+ Selected bool
+}
+
+// readerHandler serves GET /reader: a book picker (dialect names), chapter
+// 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 {
+ return func(w http.ResponseWriter, r *http.Request) {
+ books := tbl.Books(cfg.SiglaLang())
+ if len(books) == 0 { // defensive: embedded table always has books
+ http.Error(w, "web: no books", http.StatusInternalServerError)
+ return
+ }
+
+ info, ok := findBook(books, r.URL.Query().Get("book"))
+ if !ok {
+ info = books[0]
+ }
+ versions := requestReaderVersions(cfg, r)
+ chaps := UnionChapters(info.Canonical)
+ first := 1
+ if len(chaps) > 0 {
+ first = chaps[0]
+ }
+ chap := clampChap(atoiDefault(r.URL.Query().Get("chap"), first), chaps)
+ display := requestDisplay(cfg, r)
+ theme := r.URL.Query().Get("theme")
+ if theme == "" {
+ theme = cfg.WebTheme
+ }
+
+ reading := RenderPassage(info.Canonical, info.Name, chap, versions, display, cfg.UILanguage)
+
+ bookOpts := make([]bookOpt, 0, len(books))
+ for _, b := range books {
+ bookOpts = append(bookOpts, bookOpt{Value: b.Canonical, Label: b.Name, Selected: b.Canonical == info.Canonical})
+ }
+ chapOpts := make([]chapOpt, 0, len(chaps))
+ for _, c := range chaps {
+ chapOpts = append(chapOpts, chapOpt{N: c, Selected: c == chap})
+ }
+ selected := map[string]bool{}
+ for _, v := range versions {
+ selected[v] = true
+ }
+ versionOpts := make([]versionOpt, 0, len(readerCorpusVersions))
+ for _, v := range readerCorpusVersions {
+ versionOpts = append(versionOpts, versionOpt{Code: v, Checked: selected[v]})
+ }
+ themes := Themes()
+ themeOpts := make([]themeOpt, 0, len(themes))
+ for _, name := range themes {
+ themeOpts = append(themeOpts, themeOpt{Name: name, Selected: name == theme})
+ }
+
+ prev, next := chap-1, chap+1
+ if len(chaps) > 0 {
+ prev = clampChap(chap-1, chaps)
+ next = clampChap(chap+1, chaps)
+ }
+
+ data := readerData{
+ BookOpts: bookOpts,
+ Chap: chap,
+ PrevChap: prev,
+ NextChap: next,
+ ChapOpts: chapOpts,
+ VersionOpts: versionOpts,
+ Display: display,
+ ThemeOpts: themeOpts,
+ Theme: theme,
+ Mono: queryBool(r, "mono", cfg.WebMono),
+ Reading: reading,
+ L: i18n.Get(cfg.UILanguage),
+ Lang: cfg.UILanguage,
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := tmpl.ExecuteTemplate(w, "reader.html", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }
+}
+
// themeCSSHandler serves one theme's stylesheet: the requested name, or
// (unknown/invalid) cfg.WebTheme, or (that also unknown) the built-in
// default -- so an unrecognized ?name= degrades to a working theme instead
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index 809ee59..3bb5ea7 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -232,6 +232,57 @@ func TestServer(t *testing.T) {
})
}
+func TestReaderDefault(t *testing.T) {
+ srv := NewServer(config.Default())
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader", nil))
+ if rec.Code != 200 {
+ t.Fatalf("status %d", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, `id="reader-root"`) || !strings.Contains(body, `id="pane"`) {
+ t.Errorf("reader page missing root/pane")
+ }
+ if !strings.Contains(body, "Genesis") { // book <option> label (en dialect)
+ t.Errorf("book options missing Genesis")
+ }
+}
+
+func TestReaderPassage(t *testing.T) {
+ srv := NewServer(config.Default())
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader?book=John&chap=3&v=wuj", nil))
+ if rec.Code != 200 {
+ t.Fatalf("status %d", rec.Code)
+ }
+ if b := rec.Body.String(); !strings.Contains(b, "John 3") || !strings.Contains(b, "3:16") {
+ t.Errorf("passage missing heading/verse")
+ }
+}
+
+func TestReaderCompareAndBTFilter(t *testing.T) {
+ srv := NewServer(config.Default())
+ rec := httptest.NewRecorder()
+ // bt must be dropped (no corpus); wuj+vul compared side by side.
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader?book=John&chap=3&v=bt&v=wuj&v=vul&display=vertical", nil))
+ if rec.Code != 200 {
+ t.Fatalf("status %d", rec.Code)
+ }
+ b := rec.Body.String()
+ if !strings.Contains(b, "display-vertical") {
+ t.Errorf("vertical compare layout missing")
+ }
+}
+
+func TestReaderInvalidBook(t *testing.T) {
+ srv := NewServer(config.Default())
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader?book=Nonsense", nil))
+ if rec.Code != 200 { // defaults to the first book, never 500
+ t.Errorf("invalid book status %d want 200", rec.Code)
+ }
+}
+
// TestRenderOrErrorNoSectionsLang checks the "no readings at all for this
// day" fragment (finding §1) follows lang instead of always being Polish.
func TestRenderOrErrorNoSectionsLang(t *testing.T) {
diff --git a/internal/web/static/base.css b/internal/web/static/base.css
index 1e30b1d..a233e9d 100644
--- a/internal/web/static/base.css
+++ b/internal/web/static/base.css
@@ -94,6 +94,18 @@ body.mono {
color: var(--fg);
}
+/* Reader nav link + chapter stepper — layout only (themes set colour). */
+.nav-link {
+ font-family: var(--font-ui);
+ font-size: 0.85rem;
+ text-decoration: none;
+}
+.chap-nav {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
/* Day-info header (feast/day name + optional temporal season):
* RenderReadings prepends this once, ahead of every layout's own sections.
* It reuses the .heading/.citation role classes (rather than a bespoke
diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html
index 780224f..26854a8 100644
--- a/internal/web/templates/index.html
+++ b/internal/web/templates/index.html
@@ -57,6 +57,8 @@
<option value="interlinear" {{if eq .Display "interlinear"}}selected{{end}}>{{.L.OptInterlinear}}</option>
</select>
</label>
+
+ <a class="nav-link" href="/reader">{{.L.NavReader}} &rarr;</a>
</form>
<label class="theme-picker">{{.L.Theme}}
diff --git a/internal/web/templates/reader.html b/internal/web/templates/reader.html
new file mode 100644
index 0000000..c82d08e
--- /dev/null
+++ b/internal/web/templates/reader.html
@@ -0,0 +1,72 @@
+{{/* reader.html — lectio-web Bible reader: pick a book (sigla-dialect names),
+ navigate chapters, compare corpus versions. The controls form re-fetches
+ /reader and hx-selects #reader-root (so the book/chapter/version selects
+ re-render in sync); the theme/mono controls live OUTSIDE #reader-root so
+ they persist across swaps, exactly like index.html. Reuses the same
+ reading-pane templates, role classes and themes as the daily view. */}}
+<!doctype html>
+<html lang="{{.Lang}}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>lectio — reader</title>
+<link rel="stylesheet" href="/static/base.css">
+<link id="theme" rel="stylesheet" href="/theme.css?name={{.Theme}}">
+<script src="/static/htmx.min.js"></script>
+</head>
+<body{{if .Mono}} class="mono"{{end}}>
+<div class="page">
+ <div id="reader-root">
+ <form class="controls" action="/reader" method="get"
+ hx-get="/reader" hx-target="#reader-root" hx-select="#reader-root" hx-swap="outerHTML" hx-trigger="change">
+ <a class="nav-link" href="/">&larr; {{.L.BannerReadings}}</a>
+
+ <label>{{.L.WebBook}}
+ <select name="book">
+ {{range .BookOpts}}<option value="{{.Value}}" {{if .Selected}}selected{{end}}>{{.Label}}</option>{{end}}
+ </select>
+ </label>
+
+ <span class="chap-nav">
+ <button type="button" hx-get="/reader" hx-target="#reader-root" hx-select="#reader-root"
+ hx-swap="outerHTML" hx-vals='{"chap":"{{.PrevChap}}"}'>&larr;</button>
+ <label>{{.L.WebChapter}}
+ <select name="chap">
+ {{range .ChapOpts}}<option value="{{.N}}" {{if .Selected}}selected{{end}}>{{.N}}</option>{{end}}
+ </select>
+ </label>
+ <button type="button" hx-get="/reader" hx-target="#reader-root" hx-select="#reader-root"
+ hx-swap="outerHTML" hx-vals='{"chap":"{{.NextChap}}"}'>&rarr;</button>
+ </span>
+
+ {{range .VersionOpts}}
+ <label><input type="checkbox" name="v" value="{{.Code}}" {{if .Checked}}checked{{end}}> {{.Code}}</label>
+ {{end}}
+
+ <label>{{.L.Layout}}
+ <select name="display">
+ <option value="vertical" {{if eq .Display "vertical"}}selected{{end}}>{{.L.OptColumns}}</option>
+ <option value="horizontal" {{if eq .Display "horizontal"}}selected{{end}}>{{.L.OptHorizontal}}</option>
+ <option value="interlinear" {{if eq .Display "interlinear"}}selected{{end}}>{{.L.OptInterlinear}}</option>
+ </select>
+ </label>
+
+ <div id="pane">{{.Reading}}</div>
+ </form>
+ </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>
+
+</div>
+</body>
+</html>