The "top search bar doesn't work" report had TWO stacked causes, found
by reproducing with real keypress-by-keypress typing instead of
Playwright's fill() (which fires a single input event and masked both):
1. htmx.min.js was included PER PAGE. The header's live search is
htmx-driven on every page, but five pages rendering @Header —
series, admin, admin hash-conflicts, admin processing-issues,
browse-detail — never loaded it: the input's hx-* attributes were
inert (htmx-internal-data absent), so typing produced no request at
all. The include now lives in the header partial itself (classic
script, before the module bundle); the sixteen per-page copies on
@Header pages are removed (double-processing guard). Non-header
pages (login/register/setup/reader/index) keep their own.
2. htmx's delay: modifier on a PROGRAMMATICALLY dispatched custom event
(htmx.trigger) loses the request entirely under rapid successive
triggers — every re-fire canceled the pending timer and the last
never issued, so fast typing (the normal kind) never opened the
dropdown while one-shot fill() worked. The debounce moved into the
Alpine component (client-local state, where it belongs); htmx now
executes the bare `search` event immediately.
Also made bundle staleness structurally impossible: each bundle's
script stamp is a content hash computed at startup (templates/
bundles.go), so a rebuild that changes bundle bytes changes its cache
key with no manual ?v=N bump — the manual stamp was how the first
dropdown fix stayed invisible for a deploy. The reader bundle (never
stamped at all) is covered too. HTML pages additionally send
Cache-Control: no-cache (they are dynamic), so a stale page can no
longer reference a stale stamp; /api, /static and /uploads keep their
native caching, pinned by a middleware test.
Three defects from real-world use:
- The m4b rewrite assumed every stream survives -map 0 -c copy; files
carrying text-ish tracks (the movenc 'text' fourcc) fail the ipod
muxer with "Tag text incompatible with output codec id" — exactly the
error a write attempt hit in the field. The rewrite now attempts full
fidelity first, then retries mapping audio + attached pictures only
(-dn -sn): chapters live in the container header (-map_chapters 0)
so they survive; every attempt still ffprobe-validates before the
atomic rename and the original is never touched on total failure.
- The browser confirm() is replaced by an in-app dialog
(WriteToFileConfirmDialog, Alpine + templ — no native dialogs).
- "Write to file" now SAVES the pending editor edits first (persisted
through the same PUT the Save button uses), then writes the file
from those DB values, and closes the editor + refreshes the page
exactly like Save — previously the endpoint wrote the LAST-SAVED DB
values, so edits sitting in the modal never reached the file, and
the page didn't visibly update.
Test coverage per the owner's bar: EVERY single-file container (MP3,
FLAC, M4A, M4B) gets its own runtime-generated fixture, gets written,
and the FILE's metadata is then verified independently — ffprobe reads
the rewritten container and every mapped key is asserted per format,
including the subtitle round-trip split (MP3/FLAC yes, M4A/B no) — and
the EPUB/CBZ/sidecar assertions now PARSE the written XML (dc titles/
creators, ComicInfo fields, calibre meta) instead of substring
matching. A smart-genre unit test also pins the mixed-blob contract: a
recognized shelf wins the genre column even when demoted format words
ride along in the same blob.
script-src now allow-lists the guard by sha256 of its rendered bytes
(middleware.ScriptHashThemeFlashGuard) instead of 'unsafe-inline' — the
guard is the app's only inline <script>. security_headers_test.go renders
templates.ThemeFlashGuard() and fails with the replacement hash if the
script text ever drifts.
Executing anti-XSS strict CSP surfaced three latent violations the
eval-removal sweep couldn't see (hash mode is stricter than eval mode,
because inline event handler ATTRIBUTES now count against script-src):
1. admin_archived / admin_library: 15 native onclick=/onsubmit= handlers
(modal open/close, folder-browser picker, purge button) converted to
Alpine @click/@submit component methods — openCreateLibraryModal,
hideModal, pickBrowsePath added to the admin component;
purgeArchivedItems promoted from its window global into the component
and the (window as any) exposure removed.
2. dashboard/docs: onclick handlers converted — dashboard's
event.stopPropagation() → @click.stop; docs' sidebar/search closers →
closeDocsSidebar/closeSearchResults component methods.
3. 8 onerror= image-fallback attributes (cover placeholders app-wide +
audiobook cover hide) replaced by data-fallback attributes handled by
one delegated capture-phase listener in web/src/image_fallback.ts —
works for htmx-swapped content without re-binding.
Verified: theme guard applies a saved theme on public pages under the
hash CSP; 14-page authenticated sweep clean; admin pages clean under the
promoted admin; admin-library modal open/close bindings exercised;
readers render error-free; go build/vet/middleware-test, tsc, vp
check/test (146), build:ts all green.
- A progress sync conflict used to be flagged only inside the progress
card, far down a long page. An unresolved conflict now also renders
a full-width warning strip at the very top (under the app bar) that
opens the same resolution modal on click. Rendered from the same
ActiveConflict the progress section uses — absent when clean.
- Shortcuts dialog Mouse section gains an 'End time' row: click to
cycle total → book left → chapter left (§10's toggle was
undiscoverable).
templ's codegen wraps style attribute expressions in a sanitizer AND
templ.EscapeString, so any quote character — even inside templ.SafeCSS
— reached the DOM as literal ' text. The hero backdrop's url('...')
never parsed: no blurred cover on any book detail page, and Firefox
resolved the mangled token as a relative URL, fetching /media/& (500)
once per page load.
Fix: emit quote-free CSS — an unquoted url(...) token built through a
new cssURL() helper that percent-encodes the characters that would
break it (space, quotes, parens, backslash). Stored cover paths are
already percent-encoded URL paths, so the escaping is defensive only.
Frontend half of the faceted-navigation Phase 2. 'Everything filterable
should be clickable': every displayed facet value links to the bookshelf
pre-filtered by its raw stored value; display forms are prettified at
render, never in the DB.
- templates/facet_links.templ: FacetLink (inline links) + FacetBadge
(badge links) to /bookshelf?<facet>_filter=<value>; the raw value
rides the param while the shown text is prettified, so matching and
display can never disagree. Series/tags keep their dedicated pages
by design; the card media-type icon stays unlinked (a derived kind,
not a column value — deep-linking it would send wrong values).
- book_detail.templ: publisher, language, edition, genre, imprint
cells + narrators line + age-rating/story-arc badges are now links.
- prettifyGenre (templates/utils.go + unit tests): title-cases only
entirely-lowercase values; small words stay down except first
position; & preserved. TS port for chip labels documents the twin.
- bookshelf.templ + bookshelf.ts: active-filter chips bar — Alpine
renders chips from client-local form state (sanctioned island);
one bubbled htmx:afterRequest hook keeps chips + shareable URL in
sync; removeFilterChip clears the facet and re-applies. URL deep
links now accept all 18 facet params (hidden inputs where the
drawer has no field).
Pins changelog: Batch B/C entry in docs/contract_pins.md. Rendering
ownership guard (make lint-rendering) green.
Completes the A2c acceptance scenario: click an author on a book
detail page -> land on the bookshelf filtered to everything that
author wrote (including their share of co-authored books).
- Media detail + unscoped list resolved maps expose subtitle,
explicit, authors, publishers (the library-scoped raw branch and
the search endpoint already did — raw rows follow the SELECT).
- SearchMediaItemsUnified SELECT carries the new columns; the search
endpoint serializes the row directly, so clients get them there too.
- book_detail.templ renders each author as a link to
/bookshelf?author_filter=<name>.
- Bookshelf honors filter params in the URL on load (author_filter,
series_filter, genre_filter, publisher_filter, tags_filter,
narrator_filter, language_filter): pre-fills the form, applies it to
the first grid load. Comma-joined values are multi-select via the
server-side string_to_array split. Filtered views are shareable.
Pins widened: search rows (+7 keys), resolved keys (+4); changelog
entries in docs/contract_pins.md. Android follow-up plan written to
~/.opencode/plan/bookhoard-app-v2-compat.md.
Phases 4 + 5 of the metadata overhaul (plan §4, §5) plus §3 handler
wiring.
Filters (§4.1): author_filter/publisher_filter now match per-element
against the authors[]/publishers[] arrays — a co-authored book matches
any one of its authors — with comma-joined params acting as
OR-within-facet multi-select (string_to_array + cross unnest). Legacy
single-value params unchanged on the wire; series/genre/language
filters stay on their varchar columns; narrator filter already
per-element.
Collections API (§3.3-3.5): collection detail carries viewer +
hide_finished into the listing query and exposes up_next (visible-
library-enforced reading-queue head); PUT
/api/collections/:id/items/order (owner-only, full-order rewrite);
UpdateCollectionRequest gains hide_finished/show_up_next ([]
string last-value-wins for the hidden-false-sibling checkbox pattern)
merged server-side OVER stored view_settings so imported_from_series
provenance survives edits; EditCollectionModal renders the toggles
from view_settings.
Dashboard (§5.5): sections JSON carries is_playlist/complete
(dashboard.ts is the AGENTS-documented JSON-render exception; first
paint stays templ). Fully-read playlists render '✓ Playlist complete'
instead of an empty rail; imported playlists get a 📖 Playlist badge.
Frontend: book detail shows subtitle + full authors + Explicit badge;
edit modal gains subtitle, comma-separated authors/publishers (client-
split to arrays exactly like narrators), step=any series number,
explicit checkbox; collection book cards get playlist move ↑/↓ buttons
persisting the full order via the reorder endpoint (templ partial
re-render on failure — DOM order is client-local, API order is truth).
AGENTS.md gotcha #3 amended per plan §3.7: user collections are
dashboard sections by default; the show_on_dashboard premise is
retired.
Verified: full affected integration sweep green (pins widened for
up_next/is_playlist/complete — changelog entries in
docs/contract_pins.md); unit suite green; go build + templ + vite
green.
Phase 1 of the series/collections/metadata overhaul
(~/.opencode/plan/series-collections-metadata-overhaul.md).
Schema (in place, idempotent):
- media_items.series_number INTEGER -> DOUBLE PRECISION: real-world
ordinals are decimals (0.5, 1.5 novellas); INTEGER silently truncated
them (calibre 2.5 pinned as 2 in tests).
- + subtitle TEXT, explicit BOOLEAN (sidecar ingest; ABS explicit /
Audible isAdult).
- + authors/authors_search/publishers/publishers_search TEXT[] NOT NULL
(narrators A2a precedent). Arrays are write truth; author/publisher
varchars become trigger-regenerated display strings
(sync_author_publisher_display) so every varchar consumer (trgm
search, facets, API, OPDS/Kobo) keeps working unchanged.
- Row-level NULL normalization for the new arrays in
set_library_type_name (A2 lesson: Go nil -> explicit NULL -> 23502).
- Backfills ARRAY[author]/ARRAY[publisher] — deliberately no
comma-splitting ('Tolkien, J.R.R.' would corrupt; scanner kept only
authors[0] historically).
- collection_items.position INTEGER + (collection_id, position) index
for playlist ordering.
Go: sqlc regen; MediaMetadata/handler params gain Subtitle/Explicit/
Authors/Publishers (legacy singles still accepted, arrays win);
SeriesNumber int32 -> float64 end to end; metadata_overrides adapted
with legacy override keys ('author'/'publisher') preserved so existing
user overrides survive; bulk author edit writes the array.
Pins widened (docs/contract_pins.md): raw sqlc row key set + the six
new columns; changelog entry added.
Verified: unit suite green; integration sweep (bulk/smart collections/
scanner/stats/sync/queue/calibre/pins/audiobooks) green against the
shared dev DB after in-place migration. Pre-existing unrelated red:
TestOwnershipAndAdminTestSuite/TestAdminCreateUser timezone inheritance
fails identically at the pre-change commit (live shared state).
templ does not interpolate { expr } inside quoted attribute values — the
braces ship as literal text (AGENTS.md gotcha #1). Swept every templ
source for the pattern and fixed all of it:
Body theme classes: 22 page bodies rendered class="theme-{ user.Theme }"
literally, so every page first-painted the :root default (Tokyo Night)
and flashed when main.js applied the real theme. All now render
class={ "theme-" + user.Theme } server-side.
Attribute interpolations (26 across 8 files):
- api_explorer: initAPIExplorerDoc path/body/response and the Copy
Request/Response buttons (invalid JS, silently dead)
- collections: book-selection :checked/@change/@click ids and four
hx-get search URLs (collection_id={ collection.ID })
- conflicts: Resolve button id
- devices: Kobo sync-url/auth-token element ids (copy lookups only
worked because the id and the lookup were the same literal)
- unlinked_books: search-matches/manual-link clicks and container ids
- queue: status/priority badge classes never matched their CSS
- dashboard: Restore-section tooltip text
- collection_rules/collections: collection icon color
Free text goes through the new templates.JSONArg (json.Marshal) —
templ.JSONString in this templ version returns (string, error) and
cannot compose in attribute expressions; uuids via device.ID.String().
Includes regen-only drift from running templ generate (error-path
normalization) on files with no source change.
A2a (metadata round): narrators TEXT[] + narrators_search (tags
pattern: accumulator + GIN pairs) + abridged bool on media_items;
scanner fills from the Libation/ABS sidecar (narrators verbatim,
abridged flag) with audio-payload promotion; editorial fields ride
the real columns while audio_metadata stays scan-derived (duration/
codec/chapters). Detail/edit/save paths carry the fields; the web
edit dialog grows an Audio section (Narrator(s) comma-input, Abridged
toggle, scanned duration read-only); search weights + narrator filter
(word_similarity over narrators_search) + narrator autocomplete added.
A2c (per-media-type wording): copy helpers keyed on format_group —
Listen Now, Mark Complete/Not Complete, Listening Progress, Your
Listening Stats, Time Listening, Time listened (pages tile swapped for
audio), Position H:MM:SS / total H:MM:SS on detail + progress pages
(duration parsed from audio_metadata). Dashboard: system collections
swap to Continue Listening / Recently Listened at DISPLAY time when
the audiobooks library is selected (stored names unchanged; restores
keep working); week card = Listening Time / Sessions / Books Touched;
cover-corner badges identify audiobook (headphones) / comic / manga
cards on mixed grids — books stay unbadged; hover action icon follows
the media type.
A2b (player): the scrubber binds value to the playhead with an explicit
scrub guard (the handle previously froze at its initial max); header
row = reader-style back arrow + Chapter n/total launcher; chapters
open in a floating top-down drawer (current chapter highlighted, page
never shifts); Skip/Speed rows share one label-left layout; honest
wall-clock session accounting (playing-only deltas, paused flush).
A2d (bookmarks): note-bearing time bookmarks ride the existing
media_bookmarks substrate (notes column + sync-aware service already
there) — audio locator convention audio:<seconds>, chapter_number
filled, auto-title '<chapter> — H:MM:SS', capture popover with optional
note, drawer with jump/edit-note/delete. Zero server changes needed.
Verified live on the Libation sample: narrators backfilled by rescan;
PUT edit persisted + overrides detected (narrators, abridged); force
rescan preserved the user edit; reset-to-scanned-of-defaults restored
truth; narrator filter/autocomplete scoped by library; dashboard
listsening wording + badges render; bookmark save with note deduped to
one row.
media_reviews table mirrors media_ratings' shape (unique per user+item,
cascade deletes, updated_at for LWW) but standalone: a review without a
rating must work. Body is RAW MARKDOWN — storage and every payload carry
the source; rendering happens only at display sites (same doctrine as
notes, same sanitization pipeline: markdown-it + DOMPurify).
API: GET/PUT/DELETE /api/media-items/:id/review (PUT upserts; an empty
body deletes — clearing via an editor is a delete, not an empty row).
Book-detail page payload now carries the review beside the rating.
Page: the review renders above the synopsis as a scrolling card (same
max-height box as the summary), hydrated client-side through
renderNoteHtml; 'Write review'/'Edit review' sits next to the rating
stars and opens a raw-markdown editor dialog (save / delete / cancel).
sqlc regenerated; schema applied to the dev DB. Live-verified: upsert,
get, empty-delete, page markup, and the app round-trip against the dev
server.
The design parked in the handoff (multi-tier stats off reading_history;
bookorbit-style in data, not looks) is in. The real prerequisite work
was data: reading_history existed but NOTHING wrote to it — the whole
stats feature was sitting on an empty table.
- CAPTURE (no client changes, no repo-boundary crossings): every
successful progress write through SaveProgress (KOReader + app + web
+ Kobo + offline queue — the one chokepoint) now records one
reading_history session row: span = the interval since the PREVIOUS
write instant, pages = positive page delta when both sides know the
page index, marker rows for first contact. Spans over 2h are kept as
zero-second markers (overnight suspend→resume, offline flushes must
not inflate totals) — the honest 'sync cadence, not a stopwatch'
caveat stays. device_id only when a REAL device row matches (the
web/app progress path historically stuffs the USER id into DeviceID
— probe instead of trusting it; FK violation would silently drop
every capture). Source preserved in device_metadata.
- API: /api/stats/summary?range=7|30|90|year (totals, zero-filled
daily arrays, streaks, most-active day/weekday, top books, library
breakdown — one call), /api/stats/this-week (widget: rolling 7d vs
previous + streak), /api/stats/books/:id (lifetime + recent
sessions). All bucketed in the user's timezone; finished ≡ any push
≥0.995; visible-library lens enforced at source. Legacy
/api/analytics/* kept untouched. Range windows anchor end-of-day to
the START of today (blasting the clock time into tomorrow resolved
'today' a calendar day late — found by the streak test).
- /stats page (sidebar Stats between Progress and Devices, previously
/analytics was an orphan): range selector, five stat cards, two
Chart.js v4 charts (daily minutes bar, pages/day line — chunked via
dynamic import so main.js stays untouched), per-library breakdown,
most-read list, empty-state explainer. /analytics now 301s to /stats.
The orphaned analytics.templ + analytics.ts removed (their containers
and the page JSON never matched; the page was astub).
- Dashboard 'This Week' widget (server-rendered, suppressed on service
failure) and the book detail 'Your Reading Stats' card (lifetime
totals of the viewer's own sessions) complete the tiers: per-user
default, per-library breakdown inline, per-book on the page,
per-system/admin aggregate parked as before.
Tests: statistics_test.go (capture semantics at the chokepoint,
tz/day buckets, zero-fill, streak math, finished detection, range
validation, auth gate, widget, per-book lifetime). Full suite PASS.
The book-detail banner's highlight rows gain Delete (tombstoned
server-side, restorable from the Deleted modal — same as the reader
drawers already offered). The deleted-annotations modal now says the
retention in plain language — 'kept for 30 days' from the
annotation_tombstone_ttl_days tunable (threaded through BookDetail →
NotesHighlightsModal; 1-day singular and retention-disabled variants
handled) — instead of referencing a settings name a reader cannot see.
Objective 8a (web half). Reader notes-drawer rows gain an inline editor
(mirroring the bookmark-rename pattern) that PUTs the new content — the
stored position rides along because the server's note PUT hard-sets the
position column (a probe note proved an omitted position NULLs the
anchor). Book-detail banner rows gain Edit/Delete for location notes
and Edit-note/Add-note for the note ATTACHED to a highlight (full-replace
highlight PUT; empty content clears the note; the highlight itself is
never edited from the banner). The edit dialog is centered with the
markdown caption.
The book-detail 'Notes' button becomes 'Deleted' (badge =
deleted-annotation count) and the modal title becomes 'Deleted
annotations' — that modal only ever showed removed annotations.
Verified live in Brave: drawer inline edit round-trips content and
position; banner note edit, highlight-note edit, and delete (tombstone
appears in the Deleted modal); rename renders with the count badge.
Objective 7 (web half). The 'Annotations' button slides a right-hand
banner (annotations_banner.templ + annotations-banner.ts) fed by the
existing per-book GET endpoints: friendly labels (Section N parsed from
the CFI spine / Page N from the PDF envelope / NN% + first-words
fallbacks), capped highlight previews, rich x-html expansion through the
objective-6 markdown pipeline, and per-row 'Open reader here' jumps
riding /readers/:uuid?loc= (consumeLocJump: jump outranks stored
progress for that open; URL stripped before sync can echo it). WEB-ONLY
export: single .md or one-file-per-item zip via fflate — item format
identical both modes ('# friendly label' / '# selection text' headers,
raw markdown bodies, no raw CFIs anywhere).
Verified live in Brave: 12 restored highlights + a markdown note
labeled/expanded correctly; the jump navigated, landed on the right
chapter and consumed ?loc=; export produced the 13-item .md (friendly
headers only) and a valid 13-entry zip. Book-detail's existing
recently-deleted modal stays untouched (banner is additive).
The book detail page exposed server internals and unusable controls to
everyday users: it now computes and renders the book's absolute on-disk
location, and gates all of it behind the admin role.
- The page handler resolves library folder + relative path (verified
with os.Stat; falls back to the relative path when the file is not
found on disk) into the new MediaDetail.FileLocation field - only for
admins, so the absolute path never leaves the server for regular
users. This also makes it possible to locate sparse entries whose
metadata rows are largely blank.
- The Metadata grid gains a full-width, monospace, click-selectable
Location row (admins only).
- The Edit button and the MetadataEditorModal markup render only for
admins. The modal drives admin-only endpoints (metadata PUT, rescan,
reset), so non-admins previously saw a button and a form that could
only ever fail with 403s.
Replace the Notes & Highlights 'coming soon' stub with a real modal:
active counts plus a 'Recently deleted' section listing every tombstoned
highlight, note, and bookmark (type badge, deletion time in the user's
timezone, text preview), each with Restore and Delete-permanently
actions. Restore returns the annotation to every synced device; Delete
permanently is confirmed before purging. The list is server-rendered
from MediaDetail.DeletedAnnotations — no fetch on open.
Alpine handlers in book-detail.ts call the new restore/purge endpoints
and reload on success. style.css picks up the line-clamp utilities used
by the text previews.
Bring the tighten-ui brand treatment into new-ui:
- Replace the emoji book (📚) in the sidebar header with the book-open
icon rendered in the theme accent color, matching the tighten-ui
header brand (templates/header.templ).
- Add web/static/favicon.svg (book-open glyph, tokyo-night accent
#7aa2f7 stroke) and reference it from the <head> of all 27 page
templates, so the favicon is present on login/setup/error pages too.
- Regenerate templ output for all affected templates.
- Dashboard: page heading, bold section headers with accent icon tiles,
carousel chevrons as SVG icons with theme-aware gradients (wood-paneling
gradient classes preserved), and a cinematic BookCard (hover overlay with
a quick-action button). BookCard is now fluid so it fills both the
carousel slot and the bookshelf grid.
- Bookshelf: the filter wall becomes a search toolbar + a slide-in filter
drawer (filtersOpen state added to the bookshelf Alpine component). Every
filter input, the tristate cover toggle, tag autocomplete, save/load/clear
actions, HTMX search/sort, and pagination are preserved.
- Book detail: blurred cover backdrop hero, rounded-2xl cover, bold
typography, .btn action bar, progress/metadata cards, chip-style external
links. All interactive rating, modals, and data-attrs preserved.
- Auth/landing: brand-gradient hero for index/login/register, icon feature
cards, data-driven theme select (ThemeOptions). All ids (#theme-select,
#result, #auth-result, password-requirement ids) and Alpine init preserved.
The book detail page had no way to mark a book finished or reset its
read state from the UI. Reading state is modelled by reading_progress
alone, where 'read' is the canonical signal percentage >= 1.0 (used by
the dashboard Recently Read collection, analytics, and sync priority).
Add a single toggle button in the action row (after Read Now) whose
label is server-rendered from completion state:
- not read -> "Mark as Read" -> PUT /api/media-items/:id/progress
{ percentage: 1.0 }
- read -> "Mark as Unread" -> DELETE /api/media-items/:id/progress
Mark as Unread cannot use PUT { percentage: 0 }: the progress handler
silently ignores percentage < 0.005 when existing progress > 0.01
(internal/handlers/media.go anti-regression guard), so DELETE is the
only reliable reset.
If the book has an active sync mismatch (an unresolved sync_conflicts
row), the toggle resolves it first via POST /api/conflicts/:id/resolve
before writing progress. Order matters: resolving sets resolved_at,
arming the 10-minute HasRecentConflictResolution suppression window so
the subsequent progress write does not spawn a brand-new conflict. The
resolve winner is any valid source key from the conflict data (prefers
"web"); it does not affect the final state, which the progress write
sets. A 400 "already resolved" response is tolerated.
Notes, highlights, and ratings are independent of reading_progress (they
reference media_items, not progress) and are never affected by the
toggle. After toggling the page reloads so the progress card, Sync
Progress button, and conflict banner re-render server-side.
- templates/utils.go: add conflictWinnerSource and conflictID helpers.
- templates/book_detail.templ: data-conflict-id/winner on <body> and the
toggle button.
- web/src/book-detail.ts: toggleRead() + conflictId/conflictWinner/
readSaving state (read from <body> in init()).
- templates/book_detail_templ.go regenerated.
The book detail page only displayed user ratings as static, non-clickable
stars. The full rating CRUD stack already existed in the backend
(media_ratings table, POST/GET/PUT/DELETE /api/media-items/:id/rating)
but nothing in the web UI could create or update a rating.
Replace the display-only renderStars output for the user rating with an
Alpine.js widget that:
- Renders 5 stars, each split into two transparent hit zones so the
underlying 1-10 scale maps to half-star precision (left half = x.5,
right half = whole star).
- Shows a live hover preview via a ratingHover state field.
- Saves the rating in place through POST /api/media-items/:id/rating
(which upserts) and reflects the value immediately, with no full page
reload.
- Displays the numeric value (e.g. "3.5 / 5") and a Clear button that
issues DELETE to remove the rating.
- Reads the server-rendered value from a new data-rating attribute on
<body> during the bookDetail component init().
The community rating block is left as a display-only renderStars render
since it is imported metadata, not a user rating.
templates/book_detail_templ.go is regenerated (also picking up templ
v0.3.1020 reformatting of the generated output).
- Add clickable tag badges between comic badges and synopsis, linking to
/tags/detail?name=<tag>&library_id=<id> for browsing books by tag
- Add Contributors as comma-separated row in the metadata grid
- Add data-library-id attribute to body for tag autocomplete API calls
- Fix series badge link: append &library_id= so /series/detail works
when navigated from book detail page (was returning empty/404)
- Replace showMetadataEditorPlaceholder() toast with showMetadataEditor()
that opens the metadata editor modal
- Add data-format-group attribute to body for client-side cover generation
- Include @MetadataEditorModal(book) in the page modals section
Create a new /series/detail?name=X&library_id=Y SSR page that shows
all books in a specific series, replacing the broken approach of
linking to /bookshelf?series_filter=X (the bookshelf SSR handler
ignores all filter query params).
The series detail page features:
- Back link to /series browse page
- Library selector dropdown (full page navigation on change)
- Series name header with book count badge
- Book grid using the shared BookCard template
- Empty state for series with no books
Update all links to point to the new page:
- Series cards on /series browse page
- Series badge on book detail page
- JS-rendered cards in series.ts switchLibrary
Add seriesDetailPage Alpine component for the detail page's
library switcher (simple navigation, no AJAX needed).
Create templates/series.templ with:
- Library selector dropdown (sticky, same pattern as dashboard)
- Loading spinner overlay for AJAX library switching
- Series grid with stacked-cascade multi-cover cards
- Empty state when no series found
- Pagination with Previous/Next links
- SeriesCard sub-template linking to filtered bookshelf view
Add 'Series' nav link in header between 'All Books' and 'Collections'.
Make series badge on book detail page clickable, linking to
/bookshelf?series_filter=<name>&sort=series.
Add 'Continue Series' option to restore system collection modal.
Replace hardcoded .Format() calls with FormatInTimezone() and
FormatTimestamptzInTimezone() helpers so all timestamps display in
the user's selected timezone.
Changes:
- book_detail.templ: remove incorrect templates. package prefix
- book_detail_modals.templ: add User param to ProgressSyncModal so
timezone is available; convert Timestamp to FormatInTimezone()
- devices.templ: convert LastSync and LastSeen to FormatInTimezone()
- conflicts.templ: convert CreatedAt to FormatInTimezone()
- admin_users.templ: convert CreatedAt to FormatInTimezone() using
currentUser.Timezone
Note: DatePublished is kept as a plain date format (MM-DD-YYYY) since
it is a pgtype.Date, not a timestamp, and does not need timezone
conversion.
Replace hardcoded 12-hour Format() calls with FormatTimestamptzInTimezone()
so that the Last Read time respects the user's selected timezone preference.
Both book_detail.templ and book_detail_modals.templ now use the same
timezone-aware helper that was introduced in the timezone support feature.
Consistently format dates and times across all templates and API
handlers using MM-DD-YYYY with 12-hour clock (03:04 PM):
- analytics.go: date keys, lastSync, lastRead timestamps
- progress.go: lastUpdated timestamp in GetAllProgress
- book_detail.templ: LastReadAt, DatePublished
- book_detail_modals.templ: progress sync timestamps, LastReadAt
- devices.templ: LastSync, LastSeen
- conflicts.templ: CreatedAt
- admin_users.templ: user CreatedAt date
Fix two bugs in progress display across book detail, progress page, reader,
and sync modal templates:
1. Percentage was stored as 0.0-1.0 fraction but displayed as-if 0-100
(showing 0.5% instead of 50%). Multiply by 100 at the data source in
both GetAllProgress and GetAllProgressData handlers, and in the reader
route's ReadingProgress construction.
2. Progress bar width was never evaluated — { expr } inside style=".."
was rendered as literal text by templ, resulting in 0% width bars for
all items. Fixed by using templ's style={ expr } attribute syntax
which evaluates the Go expression (uses SanitizeStyleAttributeValues).
Also add format-aware progress display:
- Reader template: shows "45% · Page 89/196" for reflowable (estimated
pages), "127/342" for comics/PDFs (actual pages)
- Progress page: shows "Page X of Y (est.)" for reflowable, "X / Y"
for fixed layout
- Add FormatGroup and EstimatedPages to ProgressWithMedia struct
- Remove hardcoded totalPages=200 fallback in progress handler (now 0)
- Add fmt import to progress.templ for string formatting
- Change script tags from 'defer' to 'type=module' for ES module support
- Add reader.js script to reader.templ for the reader bundle
- Update book_detail.templ 'Read Now' button to link to /readers/{id}
- Remove trailing blank line from register.templ
Implement comprehensive comic metadata display features on the SSR book detail
page, supporting all 8 metadata fields from ComicInfo.xml and other sources.
## Template Changes (book_detail.templ)
### Step 1: Reading Direction Badge
- Display directional badge (RTL, LTR, VERTICAL) for manga/comics
- Uses 📖 icon with uppercase direction text
- Auto-hides when direction is "auto" (default)
- Styled with accent color for visibility
### Step 2: Community Rating Display
- Show pre-existing community rating from metadata (0.0-10.0 scale)
- Distinct from user ratings with visual differentiation
- Uses renderStars() helper for visual star display
- Shows both stars and numeric score (e.g., "★★★★☆ 8.5 / 10")
- Smaller, subtler styling than user rating
### Step 3: Comic-Specific Badges
- Age Rating: Content maturity indicator
- Black & White: Visual style badge
- Story Arc: Narrative arc name with 📚 icon
- Badges styled as pills with subtle borders
- Only display when values are present
### Step 4: Universal Series Info
- Series Count: Total items in series
- Volume: Volume/omnibus number
- Imprint: Publisher imprint (e.g., Vertigo)
### Step 5: Comic-Specific Metadata
- Manga Type: Raw/Comic/Manga classification
- Scan Information: Scanner group, resolution
- Alternate Series: Different series numbering
### Step 6: Summary Section
- Display ComicInfo.xml summary when present
- Separate from description field
- Sanitized HTML output with bluemonday
- Scrollable container for long summaries
### Step 7: Metadata Notes
- Technical notes from metadata files
- Internal/useful information (scanner, source, etc.)
- Card-style display with clear typography
### Step 8: Web URL Link
- External link to info sources (Goodreads, ComicVine, etc.)
- Opens in new tab with security attributes
- Displays clean domain name
## Utils Changes (templates/utils.go)
Added helper functions:
- getAlternateSeries(): Extract alternate series from JSONB
- getDomainName(): Extract clean domain for display
- formatAlternateInfo(): Format readable alternate series string
## Implementation Plan
Updated FRONTEND_IMPLEMENTATION_PLAN_COMIC_METADATA.md with:
- Disabled markdownlint for MD013 (line length)
- Added spacing for readability
## Technical Details
- All fields use pgtype.Text/Int4/Bool for NULL handling
- Template conditionals check Valid flag before accessing values
- Consistent styling using CSS custom properties
- HTML escaping for security (except summary with bluemonday)
- Responsive design with mobile-friendly layouts
Related: Database schema already supports all metadata fields
Improve star rating display on book detail page to show always-visible
5-star rating scale with theme-aware colors and visual half-star rendering.
Changes:
Enhanced renderStars() function:
- Always displays 5 stars (0/5 now shows 5 grey stars instead of empty)
- Filled stars use var(--accent) color (theme-aware highlight)
- Empty stars use var(--text-secondary) (theme-aware grey, adapts to light/dark themes)
- Half-stars use CSS linear-gradient (90deg) to split star vertically:
- Left half: var(--accent) (filled, color)
- Right half: var(--text-secondary) (empty, grey)
- Uses webkit-background-clip and text-fill-color transparent for gradient effect
Added getBookRating() helper function:
- Returns rating value or 0 if book.Rating is nil
- Allows unrated books to display 0/5 (5 grey stars)
- Makes rating section always visible instead of hiding when nil
Template changes:
- Updated rating display to always show (no conditionals)
- Removed text-yellow-400 class (colors now inline with theme vars)
- Added templ.Raw() wrapper for HTML rendering (prevents escaping)
- Simplified rating display logic
Benefits:
- Users can now see rating scale even when book isn't rated
- Visual half-star is much more intuitive than ½ text character
- Theme-aware colors adapt to light/dark mode automatically
- Follows existing patterns ( UnsafeHTML, CSS variables, etc.)
This makes the rating section more discoverable and user-friendly.
Implement SSR-first book detail page at /media/:uuid with complete
book information, progress tracking, and interactivity.
Features:
- Cover image (256x384px) with responsive layout
- Complete metadata: title, author, description, publisher, ISBN,
language, edition, page count, genre, copyright year, format
- External service links (Goodreads, Open Library, Google Books, Amazon)
with smart URL fallback: ID → ISBN → Title+Author
- Reading progress display with device sources (web/kobo/koreader)
- Sync progress modal for conflict resolution
- Collections display as clickable badges
- Notes/highlights counter with placeholder modal
- Rating display (1-10 scale with star rendering)
- HTML sanitization for book descriptions using bluemonday
Data Structure:
- handlers.MediaDetail embeds database.MediaItems for zero duplication
- Uses existing database queries (GetMediaItem, GetMediaRating, etc.)
- Follows project pattern: no parallel type systems
Frontend:
- TypeScript modal triggers (book-detail.ts)
- Alpine.js for modal interactions
- TailwindCSS styling with theme variables
- Responsive: cover-left layout, mobile stacks vertically
Backend:
- Route: GET /media/:uuid (protected)
- Handler: inline function in frontend.go following existing pattern
- Template: SSR-first with progressive enhancement
- Returns HTML only (API uses separate /api/media-items/:id endpoint)
Files created:
- internal/handlers/media_detail.go
- templates/book_detail.templ
- templates/book_detail_modals.templ
- web/src/book-detail.ts
Files modified:
- internal/router/frontend.go (add route)
- web/src/main.ts (import module)