aboutsummaryrefslogtreecommitdiff
path: root/internal/web
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web')
-rw-r--r--internal/web/server.go193
-rw-r--r--internal/web/server_test.go68
-rw-r--r--internal/web/static/base.css7
-rw-r--r--internal/web/templates/index.html1
-rw-r--r--internal/web/templates/reader.html1
-rw-r--r--internal/web/templates/settings.html115
6 files changed, 379 insertions, 6 deletions
diff --git a/internal/web/server.go b/internal/web/server.go
index 7c90e9e..34371b7 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -12,11 +12,13 @@ import (
"io/fs"
"net"
"net/http"
+ "os"
"os/exec"
"regexp"
"runtime"
"strconv"
"strings"
+ "sync"
"time"
"github.com/lukaszkasprzak/lectio/internal/bible"
@@ -32,18 +34,40 @@ import (
// every visitor sees the same five choices regardless of their config file.
var bibleVersions = []string{"bt", "wuj", "vul", "grb", "drb"}
+// server holds the live, mutable config + book table so /settings can apply
+// changes to the running process. All handlers read a snapshot via get()/table().
+type server struct {
+ mu sync.RWMutex
+ cfg config.Config
+ tbl *bible.BookTable
+}
+
+func (s *server) get() config.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.cfg }
+func (s *server) table() *bible.BookTable { s.mu.RLock(); defer s.mu.RUnlock(); return s.tbl }
+func (s *server) apply(cfg config.Config, tbl *bible.BookTable) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.cfg = cfg
+ if tbl != nil {
+ s.tbl = tbl
+ }
+}
+
// NewServer builds lectio-web's route tree. cfg supplies the defaults
// (lectionary, versions, theme, offline) that requests can override via
-// query parameters; it is never mutated.
+// query parameters; it is never mutated -- /settings applies changes to a
+// live, mutable copy held by server, which every handler reads per request.
func NewServer(cfg config.Config) http.Handler {
tbl, _ := bible.LoadBookTable(config.UserBooksTOML())
+ s := &server{cfg: cfg, tbl: tbl}
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))
+ mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { indexHandler(s.get())(w, r) })
+ mux.HandleFunc("GET /readings", func(w http.ResponseWriter, r *http.Request) { readingsHandler(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 /theme.css", func(w http.ResponseWriter, r *http.Request) { themeCSSHandler(s.get())(w, r) })
+ mux.HandleFunc("GET /settings", settingsGet(s))
+ mux.HandleFunc("POST /settings", settingsPost(s))
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
@@ -508,6 +532,163 @@ func themeCSSHandler(cfg config.Config) http.HandlerFunc {
}
}
+// settingsData drives templates/settings.html.
+type settingsData struct {
+ Cfg config.Config
+ Books string // current books.toml text (user override, else the embedded default)
+ ThemeOpts []themeOpt // for the web_theme <select>
+ VersionOpts []versionOpt // all five, Checked if in Cfg.Versions
+ WebVersionOpts []versionOpt // all five, Checked if in Cfg.WebVersions
+ Saved bool // show a "saved" note (post-redirect)
+ Error string // validation error to show (empty = none)
+ L i18n.UI
+ Lang string
+}
+
+func newSettingsData(cfg config.Config, books, errMsg string, saved bool) settingsData {
+ themes := Themes()
+ opts := make([]themeOpt, 0, len(themes))
+ for _, name := range themes {
+ opts = append(opts, themeOpt{Name: name, Selected: name == cfg.WebTheme})
+ }
+
+ versionsSet := map[string]bool{}
+ for _, v := range cfg.Versions {
+ versionsSet[v] = true
+ }
+ versionOpts := make([]versionOpt, 0, len(bibleVersions))
+ for _, v := range bibleVersions {
+ versionOpts = append(versionOpts, versionOpt{Code: v, Checked: versionsSet[v]})
+ }
+
+ webVersionsSet := map[string]bool{}
+ for _, v := range cfg.WebVersions {
+ webVersionsSet[v] = true
+ }
+ webVersionOpts := make([]versionOpt, 0, len(bibleVersions))
+ for _, v := range bibleVersions {
+ webVersionOpts = append(webVersionOpts, versionOpt{Code: v, Checked: webVersionsSet[v]})
+ }
+
+ return settingsData{
+ Cfg: cfg,
+ Books: books,
+ ThemeOpts: opts,
+ VersionOpts: versionOpts,
+ WebVersionOpts: webVersionOpts,
+ Saved: saved,
+ Error: errMsg,
+ L: i18n.Get(cfg.UILanguage),
+ Lang: cfg.UILanguage,
+ }
+}
+
+func currentBooksText() string {
+ if b := config.UserBooksTOML(); len(b) > 0 {
+ return string(b)
+ }
+ return string(bible.DefaultBooksTOML())
+}
+
+// settingsGet renders the settings form for the live config.
+func settingsGet(s *server) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ data := newSettingsData(s.get(), currentBooksText(), "", r.URL.Query().Get("saved") == "1")
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := tmpl.ExecuteTemplate(w, "settings.html", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }
+}
+
+// settingsPost validates and persists the submitted settings + books.toml, then
+// applies them to the running server and redirects (PRG). On a validation error
+// it re-renders the form with the message and the user's edits, HTTP 200.
+func settingsPost(s *server) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ cfg := s.get() // start from live cfg so Parts/SchemaVersion are preserved
+ cfg.Lectionary = normLect(r.PostForm.Get("lectionary"), cfg.Lectionary)
+ cfg.TraditionalLang = pickLang(r.PostForm.Get("traditional_lang"), cfg.TraditionalLang)
+ cfg.UILanguage = config.NormalizeUILanguage(r.PostForm.Get("ui_language"))
+ cfg.SiglaStyle = config.NormalizeSiglaStyle(r.PostForm.Get("sigla_style"))
+ cfg.WebDisplay = config.NormalizeDisplay(r.PostForm.Get("web_display"))
+ cfg.WebTheme = r.PostForm.Get("web_theme")
+ cfg.DefaultVersion = r.PostForm.Get("default_version")
+ 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")
+
+ booksText := r.PostForm.Get("books")
+
+ // Validate config.
+ if err := config.Validate(cfg); err != nil {
+ renderSettingsError(w, cfg, booksText, err.Error())
+ return
+ }
+ // Validate books.toml (parse via the same loader the app uses).
+ var newTbl *bible.BookTable
+ if strings.TrimSpace(booksText) != "" {
+ t, berr := bible.LoadBookTable([]byte(booksText))
+ if berr != nil {
+ renderSettingsError(w, cfg, booksText, "books.toml: "+berr.Error())
+ return
+ }
+ newTbl = t
+ }
+
+ // Persist.
+ if err := config.Save(cfg); err != nil {
+ renderSettingsError(w, cfg, booksText, "saving config: "+err.Error())
+ return
+ }
+ if p, err := config.BooksPath(); err == nil {
+ _ = os.WriteFile(p, []byte(booksText), 0o644)
+ }
+
+ s.apply(cfg, newTbl)
+ http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther)
+ }
+}
+
+func renderSettingsError(w http.ResponseWriter, cfg config.Config, books, msg string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _ = tmpl.ExecuteTemplate(w, "settings.html", newSettingsData(cfg, books, msg, false))
+}
+
+// normLect normalizes a lectionary form value, falling back to def on anything invalid.
+func normLect(v, def string) string {
+ if n, ok := config.NormalizeLectionary(v); ok {
+ return n
+ }
+ return def
+}
+
+// pickLang returns v if it is "pl"/"en", else def.
+func pickLang(v, def string) string {
+ if v == "pl" || v == "en" {
+ return v
+ }
+ return def
+}
+
+func atoiOr(s string, def int) int {
+ if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
+ return n
+ }
+ return def
+}
+
// defaultWebPort is the port chooseListener prefers when cfg.WebPort is 0.
const defaultWebPort = 1099
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index 5f15796..ee36318 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -4,11 +4,13 @@ import (
"net"
"net/http"
"net/http/httptest"
+ "net/url"
"os"
"path/filepath"
"strings"
"testing"
+ "github.com/lukaszkasprzak/lectio/internal/bible"
"github.com/lukaszkasprzak/lectio/internal/config"
"github.com/lukaszkasprzak/lectio/internal/liturgy"
)
@@ -499,3 +501,69 @@ func TestReaderArrowsDriveChapSelect(t *testing.T) {
t.Errorf("server did not render the requested Luke 12")
}
}
+
+func TestSettingsGet(t *testing.T) {
+ srv := NewServer(config.Default())
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/settings", nil))
+ if rec.Code != 200 {
+ t.Fatalf("status %d", rec.Code)
+ }
+ b := rec.Body.String()
+ if !strings.Contains(b, `name="ui_language"`) || !strings.Contains(b, `name="books"`) {
+ t.Errorf("settings form missing fields")
+ }
+}
+
+func TestSettingsPostAppliesLive(t *testing.T) {
+ t.Setenv("LECTIO_CONFIG", filepath.Join(t.TempDir(), "config.toml"))
+ srv := NewServer(config.Default()) // starts ui_language=en
+
+ form := url.Values{}
+ form.Set("lectionary", "new")
+ form.Set("traditional_lang", "pl")
+ form.Set("ui_language", "pl") // change it
+ form.Set("sigla_style", "auto")
+ form.Set("web_display", "vertical")
+ form.Set("web_theme", "transfiguration")
+ form.Set("default_version", "bt")
+ form["versions"] = []string{"bt", "wuj", "vul", "grb", "drb"}
+ form.Set("books", string(bibleDefaultBooks()))
+
+ post := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
+ post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, post)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("post status %d, want 303", rec.Code)
+ }
+
+ // Live-applied: the daily page now renders Polish chrome (ui_language=pl).
+ idx := httptest.NewRecorder()
+ srv.ServeHTTP(idx, httptest.NewRequest("GET", "/", nil))
+ if !strings.Contains(idx.Body.String(), `lang="pl"`) {
+ t.Errorf("ui_language change not applied live")
+ }
+}
+
+func TestSettingsPostInvalidBooks(t *testing.T) {
+ t.Setenv("LECTIO_CONFIG", filepath.Join(t.TempDir(), "config.toml"))
+ srv := NewServer(config.Default())
+ form := url.Values{}
+ form.Set("lectionary", "new")
+ form.Set("ui_language", "en")
+ form.Set("web_display", "vertical")
+ form.Set("web_theme", "transfiguration")
+ form.Set("default_version", "bt")
+ form["versions"] = []string{"bt"}
+ form.Set("books", "this is not [valid toml")
+ post := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
+ post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, post)
+ if rec.Code != 200 || !strings.Contains(rec.Body.String(), "books.toml") {
+ t.Errorf("invalid books should re-render with an error, got %d", rec.Code)
+ }
+}
+
+func bibleDefaultBooks() []byte { return bible.DefaultBooksTOML() }
diff --git a/internal/web/static/base.css b/internal/web/static/base.css
index a233e9d..c8e1bd4 100644
--- a/internal/web/static/base.css
+++ b/internal/web/static/base.css
@@ -278,3 +278,10 @@ a {
grid-template-columns: 1fr;
}
}
+
+/* Settings form — a simple labeled column; themes own colour. */
+.settings { display: flex; flex-direction: column; gap: var(--space-2); max-width: 40rem; font-family: var(--font-ui); }
+.settings label { display: flex; flex-direction: column; gap: 0.25rem; align-items: flex-start; }
+.settings .row { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
+.settings textarea { width: 100%; font-family: var(--font-mono); }
+.settings .saved { font-family: var(--font-ui); }
diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html
index 6920fcb..3058746 100644
--- a/internal/web/templates/index.html
+++ b/internal/web/templates/index.html
@@ -62,6 +62,7 @@
</label>
<a class="nav-link" href="/reader">{{.L.NavReader}} &rarr;</a>
+ <a class="nav-link" href="/settings">settings</a>
</form>
<label class="theme-picker">{{.L.Theme}}
diff --git a/internal/web/templates/reader.html b/internal/web/templates/reader.html
index 921016a..af29e65 100644
--- a/internal/web/templates/reader.html
+++ b/internal/web/templates/reader.html
@@ -20,6 +20,7 @@
<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>
+ <a class="nav-link" href="/settings">settings</a>
<label>{{.L.WebBook}}
<select name="book">
diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html
new file mode 100644
index 0000000..bea6fb9
--- /dev/null
+++ b/internal/web/templates/settings.html
@@ -0,0 +1,115 @@
+{{/* settings.html — lectio-web /settings page: a form for every config
+ setting plus a raw books.toml editor. A plain POST form (no htmx) --
+ saving redirects (PRG) back here with ?saved=1. On a validation error
+ the form re-renders with the submitted values and an inline message
+ instead of redirecting. Self-contained and theme-aware, like index.html. */}}
+<!doctype html>
+<html lang="{{.Lang}}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>lectio — settings</title>
+<link rel="stylesheet" href="/static/base.css">
+<link id="theme" rel="stylesheet" href="/theme.css?name={{.Cfg.WebTheme}}">
+<script src="/static/htmx.min.js"></script>
+</head>
+<body{{if .Cfg.WebMono}} class="mono"{{end}}>
+<div class="page">
+
+ <p><a class="nav-link" href="/">&larr; {{.L.BannerReadings}}</a></p>
+
+ {{if .Saved}}<p class="saved">Saved.</p>{{end}}
+ {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+
+ <form class="settings" method="post" action="/settings">
+
+ <label>{{.L.Lectionary}}
+ <select name="lectionary">
+ <option value="new" {{if eq .Cfg.Lectionary "new"}}selected{{end}}>{{.L.OptModern}}</option>
+ <option value="traditional" {{if eq .Cfg.Lectionary "traditional"}}selected{{end}}>{{.L.OptTraditional}}</option>
+ </select>
+ </label>
+
+ <label>traditional language
+ <select name="traditional_lang">
+ <option value="pl" {{if eq .Cfg.TraditionalLang "pl"}}selected{{end}}>pl</option>
+ <option value="en" {{if eq .Cfg.TraditionalLang "en"}}selected{{end}}>en</option>
+ </select>
+ </label>
+
+ <label>UI language
+ <select name="ui_language">
+ <option value="en" {{if eq .Cfg.UILanguage "en"}}selected{{end}}>en</option>
+ <option value="pl" {{if eq .Cfg.UILanguage "pl"}}selected{{end}}>pl</option>
+ </select>
+ </label>
+
+ <label>sigla style
+ <select name="sigla_style">
+ <option value="auto" {{if eq .Cfg.SiglaStyle "auto"}}selected{{end}}>auto</option>
+ <option value="polish" {{if eq .Cfg.SiglaStyle "polish"}}selected{{end}}>polish</option>
+ <option value="english" {{if eq .Cfg.SiglaStyle "english"}}selected{{end}}>english</option>
+ </select>
+ </label>
+
+ <label>{{.L.Layout}}
+ <select name="web_display">
+ <option value="horizontal" {{if eq .Cfg.WebDisplay "horizontal"}}selected{{end}}>{{.L.OptHorizontal}}</option>
+ <option value="vertical" {{if eq .Cfg.WebDisplay "vertical"}}selected{{end}}>{{.L.OptColumns}}</option>
+ <option value="interlinear" {{if eq .Cfg.WebDisplay "interlinear"}}selected{{end}}>{{.L.OptInterlinear}}</option>
+ </select>
+ </label>
+
+ <label>{{.L.Theme}}
+ <select name="web_theme">
+ {{range .ThemeOpts}}<option value="{{.Name}}" {{if .Selected}}selected{{end}}>{{.Name}}</option>{{end}}
+ </select>
+ </label>
+
+ <label>default version
+ <select name="default_version">
+ {{range .VersionOpts}}<option value="{{.Code}}" {{if eq .Code $.Cfg.DefaultVersion}}selected{{end}}>{{.Code}}</option>{{end}}
+ </select>
+ </label>
+
+ <div class="row">
+ versions
+ {{range .VersionOpts}}<label><input type="checkbox" name="versions" value="{{.Code}}" {{if .Checked}}checked{{end}}> {{.Code}}</label>{{end}}
+ </div>
+
+ <div class="row">
+ web versions
+ {{range .WebVersionOpts}}<label><input type="checkbox" name="web_versions" value="{{.Code}}" {{if .Checked}}checked{{end}}> {{.Code}}</label>{{end}}
+ </div>
+
+ <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}}> offline</label>
+ <label><input type="checkbox" name="web_mono" {{if .Cfg.WebMono}}checked{{end}}> {{.L.Mono}}</label>
+ </div>
+
+ <label>width
+ <input type="number" name="width" value="{{.Cfg.Width}}">
+ </label>
+
+ <label>web port
+ <input type="number" name="web_port" value="{{.Cfg.WebPort}}">
+ </label>
+
+ <label>pager
+ <input type="text" name="pager" value="{{.Cfg.Pager}}">
+ </label>
+
+ <label>books.toml
+ <textarea name="books" rows="20">{{.Books}}</textarea>
+ </label>
+
+ <div class="row">
+ <button type="submit">Save</button>
+ </div>
+
+ </form>
+
+</div>
+</body>
+</html>