3c935ba7c3
The card action strip is icon-only, so nothing on screen said what the six
glyphs do. This adds a permanent key line under the tabs naming each one.
The key is tab-shaped, not row-shaped: archived and finished swap Archive
for Restore, and finished drops Done — the same conditions card.html already
uses for the buttons. It rides along in writeChromeOOB, otherwise an htmx
tab switch would leave the previous bucket's key behind.
Also from the Claude Design pass:
• per-action accent on hover/press — slate archive, moss finished, clay
chapter — so a press says which lane it belongs to; ember stays reserved for
new-chapter heat
• cover 74→80px desktop / 86→93px phone, title 19→21px, meta 10→11px
• favourite mark pinned to the right edge of the measure instead of trailing
the title, which drifted whenever a new-chapter title shrink-wrapped
• recent strip sizes now derive from --cover-w instead of repeating magic
numbers
### Brand mark
The design project grew a logo, so it lands in three places with three
different colour sources:
• `backend/static/logo.svg` — fixed palette, because a favicon has no page to
inherit from. Linked as `rel="icon"` from both the app and the login page.
• `{{define "mark"}}` in chrome.html — takes `--ink`/`--ember`/currentColor, so
the header mark flips with the light/dark theme. Used by app.html and
login.html.
• the userscript panel header — same drawing inside the shadow root, next to
the tokens it uses.
`.brand` becomes a flex row in both stylesheets and the topbar aligns centre
rather than baseline. The 5px stroke on the 200-unit grid thins out at brand
size, so it is nudged to 6.5 instead of scaled blindly. No mark on the edge
tab: a 7px sliver has nowhere to put one.
go test ./... passes (static-asset test now covers logo.svg), node --test on
the userscript logic suite passes 14/14. Verified live at 412px and desktop:
key renders, per-tab variant correct on /?tab=finished (play/star/pencil/undo/
trash, no Done), and the OOB swap fires on /ui/list.
Reviewed-on: #12
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
445 lines
15 KiB
Go
445 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"embed"
|
|
"html/template"
|
|
"io/fs"
|
|
"log"
|
|
"math"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
//go:embed templates
|
|
var templateFS embed.FS
|
|
|
|
//go:embed static
|
|
var staticFS embed.FS
|
|
|
|
// recentCount is how many series the "Continue reading" strip shows.
|
|
const recentCount = 5
|
|
|
|
// webHandler serves the browser UI: full pages at / and htmx fragments at /ui/.
|
|
// It is a separate handler from bookmarkHandler because the two speak different
|
|
// representations (HTML versus JSON) to different clients under different auth.
|
|
type webHandler struct {
|
|
store *Store
|
|
tmpl *template.Template
|
|
key []byte
|
|
password string
|
|
limiter *loginLimiter
|
|
}
|
|
|
|
// listView is what every list-rendering template receives.
|
|
type listView struct {
|
|
Tab string // "all", "fav", or "new"
|
|
Recent []Bookmark
|
|
Items []Bookmark
|
|
// NewCount is the badge on the Updated tab: how many series being read
|
|
// have a chapter out that has not been read. It is counted over the whole
|
|
// reading set, not the active tab, so the badge does not change meaning as
|
|
// the user moves between tabs.
|
|
NewCount int
|
|
// OOB marks a render of the chrome partials as an out-of-band swap rather
|
|
// than the inline copy app.html lays out.
|
|
OOB bool
|
|
}
|
|
|
|
// Initial is the monogram the templates show in place of a cover when the
|
|
// source site never gave us an og:image. First rune, uppercased; "?" when even
|
|
// the title is missing, so the slot is never empty.
|
|
func (b Bookmark) Initial() string {
|
|
for _, r := range b.Title {
|
|
return strings.ToUpper(string(r))
|
|
}
|
|
return "?"
|
|
}
|
|
|
|
// loginView is what the login template receives.
|
|
type loginView struct {
|
|
Error string
|
|
}
|
|
|
|
// newWebHandler parses every template up front so a broken one kills the
|
|
// process at startup rather than the first request that touches it.
|
|
func newWebHandler(store *Store, cfg Config) (*webHandler, error) {
|
|
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &webHandler{
|
|
store: store,
|
|
tmpl: tmpl,
|
|
key: sessionKey(cfg.Token, cfg.WebPassword),
|
|
password: cfg.WebPassword,
|
|
limiter: newLoginLimiter(),
|
|
}, nil
|
|
}
|
|
|
|
func (h *webHandler) register(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /{$}", h.index)
|
|
mux.HandleFunc("POST /login", h.login)
|
|
mux.HandleFunc("POST /logout", h.logout)
|
|
mux.Handle("GET /static/", staticHandler())
|
|
|
|
mux.HandleFunc("GET /ui/list", h.requireSession(h.uiList))
|
|
mux.HandleFunc("POST /ui/bookmarks/{key}/favorite", h.requireSession(h.uiFavorite))
|
|
mux.HandleFunc("POST /ui/bookmarks/{key}/status", h.requireSession(h.uiStatus))
|
|
mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter))
|
|
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete))
|
|
}
|
|
|
|
// staticHandler serves the embedded assets. An hour, not longer: assets are
|
|
// not fingerprinted, and embed.FS reports a zero ModTime, so http.FileServer
|
|
// emits no Last-Modified or ETag and a client has no way to revalidate a
|
|
// cached copy after a deploy short of waiting out max-age.
|
|
func staticHandler() http.Handler {
|
|
sub, err := fs.Sub(staticFS, "static")
|
|
if err != nil {
|
|
panic("embed static: " + err.Error())
|
|
}
|
|
// Go's built-in table has no .woff2 and the scratch image has no
|
|
// /etc/mime.types, so without this the fonts go out as
|
|
// application/octet-stream.
|
|
if err := mime.AddExtensionType(".woff2", "font/woff2"); err != nil {
|
|
panic("woff2 mime: " + err.Error())
|
|
}
|
|
files := http.FileServer(http.FS(sub))
|
|
return http.StripPrefix("/static/", http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
files.ServeHTTP(w, r)
|
|
}))
|
|
}
|
|
|
|
// authed reports whether the request carries a valid session cookie.
|
|
func (h *webHandler) authed(r *http.Request) bool {
|
|
c, err := r.Cookie(sessionCookieName)
|
|
return err == nil && verifySession(h.key, c.Value, time.Now().UnixMilli())
|
|
}
|
|
|
|
// requireSession guards the fragment endpoints. It answers 401 rather than
|
|
// redirecting, because htmx swaps whatever body it receives into the page and a
|
|
// redirected login page would be spliced into the card list.
|
|
func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !h.authed(r) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func (h *webHandler) render(w http.ResponseWriter, status int, name string, data any) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
|
// The status line is already sent, so this can only be logged.
|
|
log.Printf("render %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// index renders the list, or the login page when there is no session. The login
|
|
// page is served at / with status 200 rather than as a redirect to a separate
|
|
// URL: one page, no redirect loop to reason about.
|
|
func (h *webHandler) index(w http.ResponseWriter, r *http.Request) {
|
|
if !h.authed(r) {
|
|
h.render(w, http.StatusOK, "login", loginView{})
|
|
return
|
|
}
|
|
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
|
if err != nil {
|
|
log.Printf("index: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.render(w, http.StatusOK, "app", view)
|
|
}
|
|
|
|
// filterBookmarks returns the subset keep reports true for, preserving order.
|
|
// It always returns a non-nil slice so an empty tab renders its empty state.
|
|
func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark {
|
|
out := []Bookmark{}
|
|
for _, b := range all {
|
|
if keep(b) {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// buildListView loads the list once and derives both the tab-filtered items and
|
|
// the recent strip from it.
|
|
//
|
|
// Archived and finished series appear in their own tab and nowhere else — not
|
|
// in All, not in Updated, not in Favourites, and not in the recent strip. An
|
|
// archived favourite therefore shows only under Archived: Favourites means
|
|
// "favourites I am currently reading".
|
|
func (h *webHandler) buildListView(tab string) (listView, error) {
|
|
all, err := h.store.List() // already ordered updated_at DESC
|
|
if err != nil {
|
|
return listView{}, err
|
|
}
|
|
reading := filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusReading })
|
|
|
|
withNew := filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
|
|
|
|
var items []Bookmark
|
|
switch tab {
|
|
case "fav":
|
|
items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite })
|
|
case "new":
|
|
items = withNew
|
|
case "archived":
|
|
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived })
|
|
case "finished":
|
|
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusFinished })
|
|
default:
|
|
tab = "all"
|
|
items = reading
|
|
}
|
|
// The strip is scoped to series with a chapter waiting, which is the one
|
|
// question the list below it does not already answer: the list is ordered by
|
|
// reading recency, so the head of it *is* the strip whenever the strip is
|
|
// just "the most recent rows". Only on All — on Updated it would render the
|
|
// same set twice, and on the other tabs it would contradict the bucket.
|
|
//
|
|
// It therefore disappears entirely on a library with nothing new. That is
|
|
// the intended reading: an empty strip has nothing to say, and the ~240px it
|
|
// costs on a phone belongs to the list.
|
|
var recent []Bookmark
|
|
if tab == "all" {
|
|
recent = withNew
|
|
if len(recent) > recentCount {
|
|
recent = recent[:recentCount]
|
|
}
|
|
}
|
|
return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
|
|
}
|
|
|
|
func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) {
|
|
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
|
if err != nil {
|
|
log.Printf("ui list: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.render(w, http.StatusOK, "list", view)
|
|
// The chrome is outside this response's swap target, so without this a tab
|
|
// switch would leave the strip and badge from whichever tab the page was
|
|
// loaded on — the same URL would render differently depending on how the
|
|
// reader got there.
|
|
h.writeChromeOOB(w, view)
|
|
}
|
|
|
|
// currentTab is the tab the reader is looking at, read from htmx's own header,
|
|
// so out-of-band chrome is rebuilt for that view rather than for a default.
|
|
func currentTab(r *http.Request) string {
|
|
u, err := url.Parse(r.Header.Get("HX-Current-URL"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return u.Query().Get("tab")
|
|
}
|
|
|
|
// writeChromeOOB appends the regions that live outside #list — the recent
|
|
// strip, the Updated badge and the action key — as out-of-band swaps, so a
|
|
// mutation cannot leave them describing the library as it was before the tap.
|
|
// The key is in here because it is tab-shaped too: archived and finished swap
|
|
// Archive for Restore.
|
|
func (h *webHandler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
|
view.OOB = true
|
|
for _, name := range []string{"recent", "newcount", "keyrow"} {
|
|
if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
|
|
// The card is already written; stale chrome beats a torn response.
|
|
log.Printf("render %s oob: %v", name, err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// refreshChrome rebuilds the chrome for the reader's current tab after a
|
|
// mutation and appends it to the response.
|
|
func (h *webHandler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
|
view, err := h.buildListView(currentTab(r))
|
|
if err != nil {
|
|
log.Printf("ui chrome: %v", err)
|
|
return
|
|
}
|
|
h.writeChromeOOB(w, view)
|
|
}
|
|
|
|
func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
|
|
ip := clientIP(r)
|
|
if wait := h.limiter.retryAfter(ip, time.Now()); wait > 0 {
|
|
secs := int(wait.Seconds()) + 1
|
|
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
|
h.render(w, http.StatusTooManyRequests, "login", loginView{
|
|
Error: "Too many attempts. Try again in " +
|
|
strconv.Itoa((secs+59)/60) + " min.",
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
got := r.PostFormValue("password")
|
|
if subtle.ConstantTimeCompare([]byte(got), []byte(h.password)) != 1 {
|
|
h.limiter.fail(ip, time.Now())
|
|
h.render(w, http.StatusUnauthorized, "login", loginView{Error: "Wrong password."})
|
|
return
|
|
}
|
|
|
|
h.limiter.reset(ip)
|
|
setSessionCookie(w, r, h.key)
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) {
|
|
clearSessionCookie(w, r)
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
// loadForMutation fetches the row a mutation targets, writing the error
|
|
// response itself when there is nothing to mutate.
|
|
func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bookmark, bool) {
|
|
key := r.PathValue("key")
|
|
if key == "" {
|
|
http.Error(w, "missing key", http.StatusBadRequest)
|
|
return Bookmark{}, false
|
|
}
|
|
b, ok, err := h.store.Get(key)
|
|
if err != nil {
|
|
log.Printf("ui get %q: %v", key, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return Bookmark{}, false
|
|
}
|
|
if !ok {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return Bookmark{}, false
|
|
}
|
|
return b, true
|
|
}
|
|
|
|
// saveAndRenderCard upserts and renders the row as stored, then refreshes the
|
|
// chrome. Upsert decides whether updated_at moves, so the argument's timestamp
|
|
// is only a candidate and the response must come from the return value.
|
|
//
|
|
// ponytail: the swapped card stays put even when its new status no longer
|
|
// matches the active tab. That much is deliberate — the card showing its new
|
|
// state is the feedback for the tap. The strip and the badge are not: they
|
|
// describe the whole library, so they are rebuilt out of band on every
|
|
// mutation, at the cost of one extra list read per toggle.
|
|
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b Bookmark) {
|
|
stored, err := h.store.Upsert(b)
|
|
if err != nil {
|
|
log.Printf("ui upsert %q: %v", b.Key, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.render(w, http.StatusOK, "card", stored)
|
|
h.refreshChrome(w, r)
|
|
}
|
|
|
|
// uiFavorite flips the favourite flag. last_chapter_num is untouched, so
|
|
// Upsert keeps the stored updated_at and the list does not reorder.
|
|
func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
|
b, ok := h.loadForMutation(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
b.Favorite = !b.Favorite
|
|
b.UpdatedAt = time.Now().UnixMilli()
|
|
h.saveAndRenderCard(w, r, b)
|
|
}
|
|
|
|
// uiStatus moves a bookmark between lifecycle buckets. This is the only place
|
|
// a series can be marked finished — the JSON API refuses that value, so the
|
|
// userscript cannot set it even by accident.
|
|
//
|
|
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
|
|
// list does not reorder.
|
|
func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
|
b, ok := h.loadForMutation(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
switch s := r.PostFormValue("status"); s {
|
|
case statusReading, statusArchived, statusFinished:
|
|
b.Status = s
|
|
default:
|
|
http.Error(w, "invalid status", http.StatusBadRequest)
|
|
return
|
|
}
|
|
b.UpdatedAt = time.Now().UnixMilli()
|
|
h.saveAndRenderCard(w, r, b)
|
|
}
|
|
|
|
// uiChapter forces the read chapter to a value the user typed.
|
|
//
|
|
// Writing the number also clears last_chapter_url: that URL points at the
|
|
// chapter actually read, and once the number is forced elsewhere it would send
|
|
// the reader backwards. ContinueURL then falls back to the series page, which
|
|
// is always right.
|
|
//
|
|
// A submit that does not change the number touches nothing. The form is
|
|
// pre-filled, so a bare tap of Save is an easy accidental submit; it must not
|
|
// destroy last_chapter_url, nor rewrite the last_chapter display string ("45.0"
|
|
// to "45") behind a frozen updated_at.
|
|
func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
|
b, ok := h.loadForMutation(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
raw := strings.TrimSpace(r.PostFormValue("chapter"))
|
|
num, err := strconv.ParseFloat(raw, 64)
|
|
if err != nil || num < 0 || math.IsNaN(num) || math.IsInf(num, 0) {
|
|
http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if num != b.LastChapterNum {
|
|
b.LastChapterURL = ""
|
|
b.LastChapter = raw
|
|
b.LastChapterNum = num
|
|
}
|
|
b.UpdatedAt = time.Now().UnixMilli()
|
|
h.saveAndRenderCard(w, r, b)
|
|
}
|
|
|
|
// uiDelete removes the row and answers with an empty body, which htmx swaps in
|
|
// place of the card — removing it from the page.
|
|
func (h *webHandler) uiDelete(w http.ResponseWriter, r *http.Request) {
|
|
key := r.PathValue("key")
|
|
if key == "" {
|
|
http.Error(w, "missing key", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := h.store.Delete(key); err != nil {
|
|
log.Printf("ui delete %q: %v", key, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
// The empty body is what removes the card; the chrome still has to be told
|
|
// the library got smaller.
|
|
h.refreshChrome(w, r)
|
|
}
|