summaryrefslogtreecommitdiff
path: root/internal/web
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 15:16:47 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 15:16:47 +0200
commit1e4939874a14d8abb13e82b2273bfc5f9d653295 (patch)
tree9c9eecb37fad3dc78d5bd8f58bc405d6f9cbd672 /internal/web
parente6665bd3f9ae011f65788004e4eab4472a461e8f (diff)
downloadlectio-1e4939874a14d8abb13e82b2273bfc5f9d653295.tar.gz
lectio-1e4939874a14d8abb13e82b2273bfc5f9d653295.zip
bookmarks: web reader bookmarks with notes + tags (add/list/filter/jump/delete); v0.15.0
Diffstat (limited to 'internal/web')
-rw-r--r--internal/web/server.go94
-rw-r--r--internal/web/server_test.go34
-rw-r--r--internal/web/static/base.css7
-rw-r--r--internal/web/templates/bookmarks.html50
-rw-r--r--internal/web/templates/index.html1
-rw-r--r--internal/web/templates/reader.html9
6 files changed, 194 insertions, 1 deletions
diff --git a/internal/web/server.go b/internal/web/server.go
index d7f0ebc..cd50e5a 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -12,6 +12,7 @@ import (
"io/fs"
"net"
"net/http"
+ "net/url"
"os"
"os/exec"
"regexp"
@@ -22,6 +23,7 @@ import (
"time"
"github.com/lukaszkasprzak/lectio/internal/bible"
+ "github.com/lukaszkasprzak/lectio/internal/bookmarks"
"github.com/lukaszkasprzak/lectio/internal/config"
"github.com/lukaszkasprzak/lectio/internal/i18n"
"github.com/lukaszkasprzak/lectio/internal/liturgy"
@@ -40,6 +42,7 @@ type server struct {
mu sync.RWMutex
cfg config.Config
tbl *bible.BookTable
+ bm *bookmarks.Store
}
func (s *server) get() config.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.cfg }
@@ -59,7 +62,7 @@ func (s *server) apply(cfg config.Config, tbl *bible.BookTable) {
// 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}
+ s := &server{cfg: cfg, tbl: tbl, bm: bookmarks.Open()}
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { indexHandler(s.get())(w, r) })
@@ -68,6 +71,9 @@ func NewServer(cfg config.Config) http.Handler {
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))
+ mux.HandleFunc("POST /reader/bookmark", addBookmark(s))
+ mux.HandleFunc("GET /bookmarks", listBookmarks(s))
+ mux.HandleFunc("POST /bookmarks/delete", deleteBookmark(s))
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
@@ -403,6 +409,7 @@ func atoiDefault(s string, def int) int {
type readerData struct {
BookOpts []bookOpt
+ Book string // canonical name of the selected book (for the bookmark-add form)
Chap int
PrevChap, NextChap int
ChapOpts []chapOpt
@@ -488,6 +495,7 @@ func readerHandler(cfg config.Config, tbl *bible.BookTable) http.HandlerFunc {
data := readerData{
BookOpts: bookOpts,
+ Book: info.Canonical,
Chap: chap,
PrevChap: prev,
NextChap: next,
@@ -509,6 +517,90 @@ func readerHandler(cfg config.Config, tbl *bible.BookTable) http.HandlerFunc {
}
}
+// addBookmark saves the current reader book+chapter with a note and tags, then
+// returns to the reader at that location.
+func addBookmark(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
+ }
+ book := r.PostForm.Get("book")
+ chap := atoiOr(r.PostForm.Get("chap"), 0)
+ if book != "" && chap > 0 {
+ _, _ = s.bm.Add(bookmarks.Bookmark{
+ Book: book,
+ Chapter: chap,
+ Note: strings.TrimSpace(r.PostForm.Get("note")),
+ Tags: bookmarks.ParseTags(r.PostForm.Get("tags")),
+ })
+ }
+ http.Redirect(w, r, "/reader?book="+url.QueryEscape(book)+"&chap="+strconv.Itoa(chap), http.StatusSeeOther)
+ }
+}
+
+// bookmarkView is one row of templates/bookmarks.html.
+type bookmarkView struct {
+ ID string
+ Book string // canonical (for the ?book= open link)
+ Display string // dialect display name
+ Chapter int
+ Note string
+ Tags []string
+ Created string
+}
+
+type bookmarksData struct {
+ Items []bookmarkView
+ AllTags []string
+ Tag string // active filter ("" = all)
+ L i18n.UI
+ Lang string
+}
+
+// listBookmarks renders the bookmarks page, optionally filtered by ?tag=.
+func listBookmarks(s *server) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ cfg := s.get()
+ tag := r.URL.Query().Get("tag")
+ items, err := s.bm.List(tag)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ names := map[string]string{}
+ for _, b := range s.table().Books(cfg.SiglaLang()) {
+ names[b.Canonical] = b.Name
+ }
+ views := make([]bookmarkView, 0, len(items))
+ for _, b := range items {
+ disp := names[b.Book]
+ if disp == "" {
+ disp = b.Book
+ }
+ views = append(views, bookmarkView{ID: b.ID, Book: b.Book, Display: disp, Chapter: b.Chapter, Note: b.Note, Tags: b.Tags, Created: b.Created})
+ }
+ allTags, _ := s.bm.Tags()
+ data := bookmarksData{Items: views, AllTags: allTags, Tag: tag, L: i18n.Get(cfg.UILanguage), Lang: cfg.UILanguage}
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := tmpl.ExecuteTemplate(w, "bookmarks.html", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }
+}
+
+// deleteBookmark removes a bookmark and returns to the list.
+func deleteBookmark(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
+ }
+ _ = s.bm.Delete(r.PostForm.Get("id"))
+ http.Redirect(w, r, "/bookmarks", http.StatusSeeOther)
+ }
+}
+
// 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 61546b8..f6c050b 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -597,3 +597,37 @@ func TestSettingsPostEmptyBooksPreservesFile(t *testing.T) {
t.Errorf("empty books submit clobbered books.toml: got %q", got)
}
}
+
+func TestBookmarksFlow(t *testing.T) {
+ t.Setenv("XDG_DATA_HOME", t.TempDir())
+ srv := NewServer(config.Default())
+
+ // Add via the reader form.
+ form := url.Values{}
+ form.Set("book", "John")
+ form.Set("chap", "3")
+ form.Set("note", "for God so loved")
+ form.Set("tags", "grace, gospel")
+ post := httptest.NewRequest("POST", "/reader/bookmark", 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("add status %d", rec.Code)
+ }
+
+ // Listed on /bookmarks.
+ list := httptest.NewRecorder()
+ srv.ServeHTTP(list, httptest.NewRequest("GET", "/bookmarks", nil))
+ b := list.Body.String()
+ if !strings.Contains(b, "John") || !strings.Contains(b, "for God so loved") || !strings.Contains(b, `book=John&chap=3`) {
+ t.Errorf("bookmark not listed:\n%s", b)
+ }
+
+ // Tag filter.
+ byTag := httptest.NewRecorder()
+ srv.ServeHTTP(byTag, httptest.NewRequest("GET", "/bookmarks?tag=grace", nil))
+ if !strings.Contains(byTag.Body.String(), "John") {
+ t.Errorf("tag filter dropped the bookmark")
+ }
+}
diff --git a/internal/web/static/base.css b/internal/web/static/base.css
index c8e1bd4..36f2f4d 100644
--- a/internal/web/static/base.css
+++ b/internal/web/static/base.css
@@ -285,3 +285,10 @@ a {
.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); }
+
+/* Bookmarks. */
+.bookmark-add { display: flex; flex-wrap: wrap; gap: var(--space-1); align-items: center; margin: var(--space-2) 0; font-family: var(--font-ui); }
+.bookmarks { display: flex; flex-direction: column; gap: var(--space-2); font-family: var(--font-ui); }
+.bookmark { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: baseline; }
+.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); }
diff --git a/internal/web/templates/bookmarks.html b/internal/web/templates/bookmarks.html
new file mode 100644
index 0000000..f51515b
--- /dev/null
+++ b/internal/web/templates/bookmarks.html
@@ -0,0 +1,50 @@
+{{/* bookmarks.html — lectio-web /bookmarks page: the saved book+chapter
+ bookmarks (note + tags), filterable by tag, each with an "open" link
+ back into the reader and its own delete form. A plain page like
+ index.html/settings.html's head; no htmx round trip needed for the
+ list itself (a plain GET with ?tag=), each delete is its own POST form. */}}
+<!doctype html>
+<html lang="{{.Lang}}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>lectio — bookmarks</title>
+<link rel="stylesheet" href="/static/base.css">
+<link id="theme" rel="stylesheet" href="/theme.css">
+</head>
+<body>
+<div class="page">
+
+ <p>
+ <a class="nav-link" href="/reader">&larr; reader</a>
+ <a class="nav-link" href="/">home</a>
+ </p>
+
+ <nav class="tag-filter">
+ <a class="nav-link{{if eq .Tag ""}} active{{end}}" href="/bookmarks">all</a>
+ {{range .AllTags}}
+ <a class="nav-link{{if eq $.Tag .}} active{{end}}" href="/bookmarks?tag={{.}}">{{.}}</a>
+ {{end}}
+ </nav>
+
+ {{if not .Items}}
+ <p>No bookmarks yet.</p>
+ {{else}}
+ <div class="bookmarks">
+ {{range .Items}}
+ <div class="bookmark">
+ <a href="/reader?book={{.Book}}&chap={{.Chapter}}">{{.Display}} {{.Chapter}}</a>
+ {{if .Note}}<span class="note">{{.Note}}</span>{{end}}
+ {{if .Tags}}<span class="tags">{{range .Tags}}#{{.}} {{end}}</span>{{end}}
+ <form method="post" action="/bookmarks/delete">
+ <input type="hidden" name="id" value="{{.ID}}">
+ <button type="submit">delete</button>
+ </form>
+ </div>
+ {{end}}
+ </div>
+ {{end}}
+
+</div>
+</body>
+</html>
diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html
index 3058746..d3416e9 100644
--- a/internal/web/templates/index.html
+++ b/internal/web/templates/index.html
@@ -63,6 +63,7 @@
<a class="nav-link" href="/reader">{{.L.NavReader}} &rarr;</a>
<a class="nav-link" href="/settings">settings</a>
+ <a class="nav-link" href="/bookmarks">bookmarks</a>
</form>
<label class="theme-picker">{{.L.Theme}}
diff --git a/internal/web/templates/reader.html b/internal/web/templates/reader.html
index af29e65..7e506c2 100644
--- a/internal/web/templates/reader.html
+++ b/internal/web/templates/reader.html
@@ -21,6 +21,7 @@
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>
+ <a class="nav-link" href="/bookmarks">bookmarks</a>
<label>{{.L.WebBook}}
<select name="book">
@@ -54,6 +55,14 @@
</label>
</form>
+ <form class="bookmark-add" method="post" action="/reader/bookmark">
+ <input type="hidden" name="book" value="{{.Book}}">
+ <input type="hidden" name="chap" value="{{.Chap}}">
+ <input type="text" name="note" placeholder="note">
+ <input type="text" name="tags" placeholder="tags, comma-separated">
+ <button type="submit">★ bookmark</button>
+ </form>
+
{{/* #pane is OUTSIDE the form so its reading text inherits body's
--font-reading (mono-responsive), not the form's --font-ui. It stays
inside #reader-root so an hx-select swap re-renders it with the controls. */}}