Reproduced on the emulator via CDP: clicking Save Changes fired the
submit event, something preventDefault-ed it, and htmx aborted while
parsing hx-headers — 'Bearer ' + localStorage.getItem('token') is a JS
expression, and htmx 2 requires valid JSON (or a js: prefix) there.
The PUT never left the browser: zero htmx lifecycle events, no console
error, no toast. My earlier HX-Refresh fix was correct but unreachable
— the request died before it could ever be answered.
The header was htmx-1.x legacy and redundant: the browser already sends
the token cookie, which the auth middleware accepts. Removed hx-headers
from the profile form and both password-reset buttons.
Verified end-to-end in the emulator browser via CDP: Edit → change
timezone → Save → PUT 200, HX-Refresh reload, DB updated. The tab
reload mid-probe is the fix working.
The audit showed only 5 of ~11 time-display surfaces converted to the
user's timezone; everything else rendered pgx-decoded UTC. The server
owns conversion — clients (web/app/KOReader) receive display-ready
strings or machine-readable instants and never convert.
- Progress page: LastUpdated formatted in the requesting user's tz
(was raw UTC).
- Analytics: date-range defaults, day buckets, most-active-day, device
last-sync, and popular-book last-read all computed in the user's tz —
evening sessions no longer land on the next UTC day. Also fixed the
end-date boundary: the query bounds with <= and the end bound was
midnight, so the end date's own sessions (including 'today') never
appeared.
- Queue page: new created_at_display field (admin tz) rendered by the
template; created_at stays RFC3339 for API consumers.
- Admin hash-conflicts SSR bridge: detected date rendered in the
admin's tz.
- OPDS: .UTC() before the Z-suffixed feed timestamps (honesty fix —
correct today only because the session zone happens to be UTC).
- KOReader annotation datetimes deliberately untouched (device owns its
display).
The /register checklist was dead markup: initPasswordValidation() was
only ever called on the setup page, so the requirement rows never
updated and the Register button stayed disabled forever — web
self-registration was effectively broken. The register Alpine component
now initializes it.
The validator is parameterized (field/submit ids, confirm + username/
email checks optional) so the admin Create User form reuses it: confirm
field, live requirement rows, and the Create button gated on all checks
passing. Requirements mirror the server's passwordcomplex rules
(8+ chars, upper/lower/number/special — the hardcoded defaults; server-
tuned rule values are a future nicety, the server remains the enforcer
either way).
The md:-flipped table/card split still broke at tablet widths and with
the nav sidebar open — a viewport breakpoint cannot see the sidebar
eating content width. Replaced the table entirely with the same fluid
card list admin_library uses (flex + min-w-0/truncate identity block,
fixed action cluster, flex-wrap): it reflows continuously at every
width instead of stepping between two layouts. The duplicate-DOM-id
suffixing from the previous two-rendering approach is gone with it.
- The desktop-on-Android bug: 8 page templates (admin, admin_settings,
admin_users, admin_library, admin_archived, admin_hash_conflicts,
admin_processing_issues, profile) lacked the viewport meta, so mobile
browsers fell back to a ~980px layout viewport and rendered the
desktop layout. Added the same meta the other 21 pages already had —
partials without a head were skipped deliberately.
- Admin Users page: the create-user form no longer dominates the page —
it is hidden behind an explicit '+ Create User' button (Alpine
x-show/x-cloak, the site-wide convention) with a Close control.
- The user list is responsive: table on md+, stacked per-user cards
below. Role/delete controls are shared partials with DOM-id suffixes
so both renderings keep unique swap targets (last-admin protections
preserved in both).
The family-lens hardening: library_visibility was only enforced where
file bytes flowed (download/reader/OPDS) — metadata surfaces leaked
hidden libraries to any authenticated account, and registration was
open by default with no admin-side user creation.
- Series are grouped PER LIBRARY now (same name in two libraries =
two entries: manga adaptations / LN splits no longer merge);
/api/series aggregates only the requester's VISIBLE libraries and
/api/series/books REQUIRES a visible library_id (breaking contract;
web + app clients updated in the same change).
- /api/media-items, item detail, and the reader content endpoints
(chapters/panels/outline/THUMBNAILS — page images leaked unguarded
next to a gated GetPage) all enforce the visible set; resolveLibrary
no longer honors a hand-typed library_id outside the visible set.
- Annotation/note/bookmark mutations (update/delete/tombstone, sync
LWW paths incl. conflict resolution) carry ownership predicates:
another account's bare row id is a 404, not a silent delete.
- Admin user creation: POST /api/auth/users (AdminMiddleware) + a
Create User card in the admin Users UI — accounts stay creatable
with self-service registration OFF, which is now the schema default
(fresh installs; the first-admin bootstrap exception is unchanged).
- Tests: series contract re-pinned (per-library, visibility 403s),
new ownership + admin-create integration suites, OPDS all=1 test
now passes an explicit page size (shared-DB growth made the fresh
books fall off page 1), device-cap helper switched to the admin
endpoint.
The reflowable adjust (objective 9) had no fixed-layout counterpart: the
popover's Adjust button relied on adjustContext, which is only stashed by
the reflowable show-annotation path, so PDF rows showed a button that
silently did nothing. PDF highlights persist as page-fraction rect
payloads ({v:1,page,rects}), so their adjustment is geometry, not range
re-anchoring.
- startAnyAdjust routes the popover button by format; startPdfAdjust
runs a rect-edge session: 4 grips per rect (all edges, one rect per
drag), live preview through the renderer's own addRectAnnotation
(replace-by-key — no fork change), commit via saveHighlightChanges.
- Snap vocabulary mirrors snapBoundary: maximal letter runs are atomic
tokens (start edge takes the word start, end edge the word end), each
punctuation glyph is its own token with both edges grabbable (a period
or line-break hyphen can be taken or shed precisely). Horizontal edges
snap to line top/bottom bands (own line + neighbours).
- selection_text is recomputed from the glyph boxes inside the final
rects (reading order, lines joined with spaces) so the stored text
stays honest with the extent.
- Cancel paths mirror the reflowable session: page turn (renderer
relocate) discards and restores; a committed session skips the restore.
- getContents() from the fixed-layout renderer carries no index — page
iframe docs are mapped through a WeakMap filled by the view's load
events. Pointer mapping goes through the canvas's post-transform rect
(the dpr denominator lesson) via the fork's own convertPoint ratios.
- pdfCharBoxes dedupes by box key: pdf.js re-renders append a second
span set into the same .textLayer without clearing, and undeduped
glyphs doubled the recomputed text.
Device-verified (emulator Chrome + CDP): popover route, grip placement,
word-atomic shrink ("But this" -> "this"), extension to the exact
line-break hyphen boundary (0.8777), far-drag clamp at the last token,
page-turn cancel, and the reflowable adjust still routing to handles.
The drawer was the only top-bar tool without a keyboard shortcut, so the
shortcuts & gestures help modal had to describe it as '📝 button'. Bind
plain 'a' to toggleBookmarks(), following the same pattern as the
existing t/s/b letter shortcuts.
- reader.ts: new 'a' branch in handleKeydown, guarded against
Ctrl/Cmd/Alt so Select All (a common reader action when selecting
text to highlight/copy) is never intercepted; typing guard already
protects note textareas, search box, and selects. Works in both the
host page and book-content iframes since both keydown listeners route
through handleKeydown.
- reader.templ: help modal row now shows <kbd>a</kbd> like the other
letter shortcuts; 📝 button tooltip gains '(a)' to match its
siblings (b), (/), (s), (?).
- reader_templ.go: regenerated.
- BUG: startBannerEdit extracted the note by splitting row.rich — the
RENDERED HTML — on \n\n, which the concatenated markup never
contains, so highlight-note dialogs always opened empty. Both row
kinds carry the raw note on bannerSources; use that lookup.
- Expanded body now splits at the highlight→note boundary: the
selection paragraph (left border in the row's highlight color), a
divider + pencil NOTE label, then the note markdown — instead of the
two texts flowing together.
- The note-indicator pencil (corner badge) now shows on ALL note rows,
not just highlights with attached notes.
Verified in headless Chromium: badge on note and highlight rows;
expanded highlight shows the bordered selection, NOTE label, and note
markdown; the highlight edit dialog prefills with the note text.
The All Libraries entry in the library switcher could render the
cross-library dashboard but rejected its own settings: the modal
refused to open without a library ('Select a specific library...') and
a save sent library_id: '' (server 400). The server now stores a
global scope (separate table; null/absent library_id, empty string
still 400), so:
- openDashboardSettings allows the All view and fetches prefs without
the library param (reads the global row; previously a loose NULL
OR-clause matched an arbitrary library's row).
- saveDashboardSettings sends library_id: null from All Libraries.
- The settings modal gains a scope label (#dashboard-scope-label)
showing which library — or All Libraries — a save will customize
(user requirement: always know the scope).
Verified end-to-end in headless Chromium: reorder on All Libraries →
save (null) → reload → modal re-opens with the persisted order and the
All Libraries scope label; per-library save lands in its own row
without touching global (SQL-checked).
Highlights with an attached note get a small edit-note stroke SVG
(same note-pencil path as the selection popover's Note affordance) in
the card's upper-right corner — themed via var(--text-secondary),
pointer-events: none so taps pass through, header gains pr-8 on those
rows so wrapped text never runs under it. Placement matches the
Android banner's corner badge (user preference: never left of text).
Regenerated annotations_banner_templ.go from the repo root.
Tailwind preflight zeroes p/blockquote/list margins globally, so notes
rendered through renderNoteHtml collapsed flat: blank lines between
paragraphs vanished, blockquotes were indistinguishable from body text,
lists had no bullets/indent, code blocks had no padding. The parser was
always correct — the loss was CSS at the x-html display sites.
One shared scoped block (.note-rich, in @layer components so it
outranks base preflight): p+p margin-top, blockquote bar + indent +
secondary color, list markers + padding, pre/code padding + themed bg,
modest heading sizes, underlined links, table borders, em-based so it
scales with each site's text-xs/text-sm context. Applied at all three
display sites: reader notes-drawer rows (replaces the unused
reader-note-rich hook), the 📝 highlight note line, and the banner's
expanded body.
The regenerated _templ.go files also catch up two stale .templ changes
that had shipped without regeneration (the selection popover's Adjust
button, the banner delete ternary) — the Docker build regenerates at
image-build, so the served site always matched the sources.
Verified live (headless Chromium against the dev stack): .note-rich in
the served stylesheet; p+p computed marginTop 9.6px, blockquote 3px
solid border-left, ul disc + 22.4px indent, pre padded + themed bg.
Objective 9 (web half). The selection popover's edit mode gains an
Adjust button: the overlay's show-annotation event stashes the live DOM
range, start/end teardrop handles render over it (host layer above the
iframe), and pointer drags map into the iframe's caretRangeFromPoint.
Snapping: land next to punctuation → the boundary sits exactly there;
land inside a word → the word's edge in the drag direction; whitespace
trims toward the adjacent word. A live preview repaints through the
overlayer under a scratch key; relocation (page turn) cancels safely.
Release recomputes the range CFI + collapsed-end CFI (view.getCFI) and
saves through the popover's own saveHighlightChanges path (PUT +
remove-old/add-new overlay dance), keeping selection_text and the
anchors in lockstep.
Verified live E2E in Brave: extent extended by one word with the
comma/space snap behavior, saved to the server row, handles cleaned;
all snap paths unit-exercised in-page (word end/start, punctuation
exact on both sides, whitespace trim); the test highlight was restored
to its original extent through the same adjust flow (punctuation-precision
drag back to the comma).
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).
Objective 6 (web half). Storage/sync stay raw markdown (KOReader shows
literal source, accepted). Notes-grade syntax: headings, emphasis/
strikethrough, code, links, lists, blockquotes, GFM tables, reference
links, footnotes (markdown-it-footnote); images/raw HTML/math excluded
(html:false posture + FORBID img/style/form + default safe-scheme URI
check). Display sites: annotations-drawer note rows and highlight note
lines render pre-sanitized HTML (x-html); render-time sanitize only.
Rich paste: both note textareas intercept paste; a text/html clipboard
flavor converts via turndown (gfm tables incl. a headingless-table rule
emitting pipe syntax; images blanked) and inserts at the caret; plain
pastics fall through to the default paste unchanged.
Verified live in Brave: bold/italic/strike/code/lists/quote/table/
footnote-ref/reference-link render; <script> renders inert literal
text with no alert; javascript: hrefs absent from the DOM; paste of
rich HTML lands markdown at the caret; plain pastes untouched.
One boolean (Security > Public Registration) switches the whole public
signup lifecycle: front page + login links, the logged-out sidebar's
Create-an-account (via a package-level templates hook so shared page
templates keep their signatures), GET /register -> 302 /login, and an
early 403 on POST /api/auth/register. First-user exception preserved:
zero admins keeps every route and link reachable for bootstrapping —
same 'users exist' reasoning as the setup gate. Admin user creation is
unaffected by design.
Live E2E verified: flag off hides all surfaces and blocks the POST
(403), flag on restores them (201); the admin UI renders the row
automatically.
Oversized notes previously failed only at save time, surfacing the
raw backend validator error to the reader. The reader now handles
the limit inline and keeps oversized input editable:
- Live character counter under both note textareas (annotations
drawer "Add a note" and the selection popover note editor),
formatted "12,345/100,000" — muted normally, red once over
- Pasting is never truncated; the full text stays in the textarea
so the reader can shrink it however they see fit
- The three save actions (Add Note, Highlight with note, Save
note) disable while over the limit, with a toast fallback in
addNote / createHighlight / saveHighlightChanges so the guard
holds even outside the disabled-button path
- noteMaxLength mirrors the server-side cap (100,000, see
internal/handlers/media.go) so client and server stay linked
Also truncate note rows in the drawer list — highlight rows
already truncated, but a long note previously stretched the
drawer body.
The resize handle needed no work: .reader-note-input already
ships resize: vertical in the compiled CSS; style.css is rebuilt
only for the new disabled:* utility classes.
The version tag previously existed only in git and the Docker image tag —
the running app had no way to report what build it was. Version is now
injected at build time via -ldflags into internal/version.Version (defaults
to "dev" for local builds), passed by the release workflow as the
APP_VERSION Docker build arg from the pushed tag.
Surfaced in Admin → Settings → About (extensible card for future rows like
disk space) and in the /health JSON response, so deployments can be
verified with curl alone.
Regenerates all *_templ.go files after the templ generate pass (version
stamp updates across the board; functional changes only in
admin_archived_templ.go and admin_library_templ.go, which were committed
with their features). web/static/style.css and web/static/htmx.min.js are
refreshed outputs of npm run build (tailwind pass over the current
templates; htmx copied from the current node_modules version).
Three fixes to the Delete Library flow on /admin/library:
1. Honest warning copy. The old text ("Media files will not be deleted")
read as reassurance while deleting a library actually cascade-destroys
every book record with it: reading progress, highlights, notes,
bookmarks, and ratings are gone with no archive window and no undo.
The modal now states this as a scannable list:
- Permanently removed, no archive or undo: the library, its folder
mappings, and all book records — with their reading progress,
highlights, notes, bookmarks, and ratings.
- Not touched: media files on disk.
Admins skim danger dialogs; the irreversible part now leads.
2. The dialog stayed open after confirming. The confirm button swaps
#libraries-container via htmx (so the deleted row vanished) but the
modal lives outside the swapped container and nothing closed it. An
htmx:afterRequest listener now hides the modal on successful deletes
and leaves it open on errors.
3. Latent ReferenceError in openFolderBrowser: the function parameter is
targetInputId but the body referenced an undefined targetInput, so
opening the folder browser threw and the picker never loaded.
Covers the copy-then-delete-later workflow: a user copies books to a new
library, deletes the originals, and ends up with an active copy in the new
library plus an archived twin holding the real reading history. Until now
those twins could only be purged (destroying the history) or restored
(showing a permanently broken entry).
- POST /api/media-items/:id/merge (admin only, body {target_id}):
validates the source is archived/missing, the target is active, and
both share the same file_sha256; then re-parents every child row onto
the target via the existing reparent_media_item_children function (the
same machinery as hash-conflict resolution) and deletes the source row.
Per-user collisions keep the active copy's data, mirroring that flow.
Affected data: reading progress, speed, ratings, highlights, notes,
bookmarks (including tombstoned deleted-annotation history), formats,
collections, kobo shelves/entitlements, sync rows, panel data,
processing issues, and device aliases.
- GET /admin/archived: ListHiddenMediaItems' match_* columns surface each
row's best active twin; rows with a twin get a confirm-guarded "Merge"
button (data-merge-source/-target) next to Restore/Delete, wired in
web/src/admin.ts like the existing unarchive/delete handlers.
- templates.ArchivedItem gains MatchID/MatchTitle/MatchLibraryName;
frontend.go populates them from the listing row.
After a merge the archived row is gone, so the retention purge can never
destroy the merged data. Deleted-annotation tombstones carry over and
remain restorable from the target book's "recently deleted" history.
PDFs shelved in comics or manga libraries (scanned manga, official manga
PDFs with text layers) now get comic reader treatment: bookmarks only —
no text-selection highlights, no annotations, no in-book search. Items
in ebooks libraries are completely unaffected, and EPUBs keep their
existing behavior everywhere.
- handlers: new exported ShouldTreatAsComic(libraryType, formatGroup) —
true for fixed_layout/comic_archive in manga/comics libraries; the
reader JSON API also exposes it as treat_as_comic
- router: the SSR reader page fetches the library type and passes
TreatAsComic through ReaderMetadata into the init config
- reader: when treatAsComic is set, the PDF textLayer selection listener
(mouse and touch paths) never attaches so no highlight popover can
open; pointer mode is forced to pan (Smart/Text segment hidden);
search button and toggleSearch are disabled; previously-created PDF
highlights stop rendering
- bookmarks are unchanged (already page-index based for fixed layout),
and device sync / OPDS / format classification are untouched since
format_group stays fixed_layout
Cancel only collapsed the editor section, and edit-mode saves only set
noteOpen=false — the minimized popover (colors/pencil/copy/delete) stayed
up. Explicit Save and Cancel now call hideSelectionPopover; color tweaks
with the editor open still keep it open.
- The in-book pointerdown dismiss listener lived in the reflowable-only
block, so PDF taps never closed the selection popover — hoist it so
every content doc (EPUB, PDF, comics) dismisses on tap.
- createHighlight hardcoded note_text: '' — 'Highlight with note' saved
the highlight but silently dropped the note. Send p.note (and pass it
to the EPUB overlayer).
- saveHighlightChanges collapsed the note editor on every save; color
dots in create mode created a highlight outright. With the editor
open, color dots now just recolor (create) or recolor-and-save with
the editor kept open (edit).
- tap zones: map in-iframe taps into the visible page slice (the
iframe is laid out at full section width; every tap previously
computed as the left margin) and shrink the default zone size to 12%
- hyphens: manual on coarse pointers: Chrome's touch word-selection
walks hyphen fragments, re-anchoring line-start drags and
overshooting line ends into the next column
- selection popover: opens for settled touch selections (the gesture
takeover swallows pointerup), positioned below the selection with
on-screen clamping; creating a highlight clears the selection so
the browser's own menu follows
- settle-time recovery: return the view to the selection's anchor
page and clamp the selection to the visible page after the
browser's selection auto-scroll wanders off the page grid
- overscroll-behavior: none on the reader page (pull-to-refresh
during downward drags)
Admins need to see what the archive lifecycle is holding: a dedicated
/admin/archived page listing every hidden item (archived or still in
the missing grace window) with library, file path, status, and - when
retention is enabled - the exact date it will be permanently deleted
(archived_at + ARCHIVE_RETENTION_DAYS).
Per row: Restore (POST /api/media-items/:id/unarchive, clears the
archive state so it reappears; if the file is still gone the next scan
hides it again) and Delete (existing DELETE endpoint for single-row
purge with its reading history). Purge All Archived reuses the existing
bulk button. The library admin banner links to the page and keeps its
purge button; row actions use data attributes with delegated listeners
in admin.ts since this templ version has no JSFunctionCall helper.
Verified live: page 200 with purge dates shown, unarchive returned 204
and reset the row, per-item delete and bulk purge ({purged:1}) both
removed their rows with no leftovers.
Two visibility fixes so the UI reflects the shelf's real state on the
next scan instead of only after the archive gate:
- Missing items disappear at once: the user-facing filters already hid
archived rows; the same listings now also require missing_scan_count =
0. A deleted or moved-then-not-yet-repointed file vanishes from the
UI immediately, while purge timing stays gated on archived_at plus the
retention window - grace protects data, not visibility. Restored
automatically when the file returns.
- Steam Deck SD-card model for unmounted storage: resolveLibrary stats
each library's folder roots and flags libraries with no live folder
as Offline (LibraryData gains the field, computed at request time so
mounts/unmounts react instantly). The bookshelf shows an empty shelf
plus a 'storage is not connected' notice for an offline selected
library, and both the shared LibrarySwitcher and the bookshelf's
inline select label offline libraries with their true holding counts.
Nothing is marked or purged while offline.
- TotalMediaCount skips offline libraries, so the 'All Books/Libraries'
totals match what is actually visible.
Verified live: renaming uploads/Manga away produced the notice, an
empty shelf, and the offline dropdown label with a corrected total;
renaming it back restored all 38 cards with zero dirty rows.
Bulk escape hatch for archived rows (files missing from disk for 2+
scans) so a mass external deletion never has to wait out the retention
window or be clicked away row by row:
- POST /api/media-items/purge-archived (admin only) hard-deletes all
archived items and returns the purged count; reading history goes with
the rows, so the call is confirmed in the UI first.
- The library admin page shows an 'Archived items: N' card (only when
non-zero) with a Purge Archived Now button that calls the endpoint,
toasts the result, and reloads.
- Frontend admin JS exposes window.purgeArchivedItems following the
existing localStorage-bearer-token pattern.
Complete the metadata editor modal to match the backend override work:
- File Path: read-only, monospace field showing the resolved on-disk
location (MediaDetail.FileLocation), next to Format and File Size, so
admins can see exactly which file backs the record without leaving the
editor. Renders empty for non-admins, who never receive the path.
- Reset to Scanned: new button beside Rescan. It confirms, then calls
POST /api/media-items/:id/rescan?reset_overrides=true to drop all
per-field user overrides and re-extract scanned defaults. Rescan alone
keeps customizations; Reset discards them.
Generate Cover has never worked: it fetched the book file using a URL
scraped from the cover preview <img> tag (so it downloaded either the
existing cover JPEG or, when no cover existed, the detail page HTML),
then handed it to foliate-js, which rejects both. Its fixed-layout path
also called view.renderer.renderPage(), a method that does not exist in
the pinned foliate fork. Every click ended in the same generic 'Cover
generation failed' toast.
The working alternative already exists server-side: the scanner's
PDF/EPUB cover extraction plus the per-book Rescan button, now that the
rasterizer renders the CropBox. Users who want a specific image can
upload one.
Delete web/src/cover-generator.ts, the modal buttons, and the dead
generateCover()/coverGenerating/fileUrl plumbing in book-detail.ts.
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.
Adds a Rescan button to the MetadataEditorModal footer that POSTs to the
new per-book rescan endpoint, with rescanning state (disabled + spinner,
matching the existing Mark Read pattern), success/error toasts, and a
page reload to pick up the refreshed cover and metadata.
The reader page embedded a snapshot of reading state (position,
bookmarks) server-side at render time. Browsers may reuse that HTML
(heuristic caching, bfcache), so opening a book could restore a stale
position — and worse, the restore's relocate auto-saved it back,
overwriting a newer device push minutes later. A KOReader sync followed
by opening the web reader would silently revert the row to the old web
position; the row's source and the rendered page disagreed.
The web reader is intrinsically tied to the server, so it has no business
preserving reading state client-side:
- The rendered page now carries only immutable book metadata. The reader
fetches progress fresh (cache: no-store) from the existing progress
API at open and restores with the same priority as before (page for
fixed-layout, CFI, percentage, fresh start); a failed fetch opens at
the start and writes nothing. Initial bookmarks likewise come from
their endpoint instead of the embed; annotations already did.
- Progress saves are gated on deliberate navigation only (page turns,
keys, slider, search/TOC/bookmark/back-stack jumps, tap zones — each
marks the session as user-moved). Restores and section-load
relocations never write, so displaying a position can no longer
clobber a newer one. A bfcache-resurrected page resets the flag and
cannot write its frozen position either. This replaces the old
five-second post-init suppression, which a stale page bypassed.
- The server-rendered initial progress badges render a neutral
placeholder until the first relocate fills them (sub-second).
No API, schema, or sync-engine changes. Normal reading saves exactly as
before — the first save now simply waits for the first real page turn.
- Drop UNIQUE(media_item_id,user_id,title): titles are display labels
shared verbatim across clients; same-title bookmarks on different pages
now coexist instead of 500ing (deleting over a tombstone no longer
blocks future creates with that title)
- Add origin_source column recording the creating client (android/web/
koreader), set once at insert, exposed in API responses
- Web reader auto-title mirrors KOReader's 'in <chapter>' convention,
falling back to 'Bookmark'; adds bookmark rename in the drawer
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.
Phase 4 of the reader redesign (foliate-js ea268df):
- Webtoon mode for comics: continuous vertical scroll of all pages
(900px centered column on wide screens), lazy-loaded with a 150%
IntersectionObserver margin, far pages unloaded to bound memory
with stable aspect-ratio placeholders so the scrollbar never jumps.
Chosen per book (Paged | Webtoon segmented control in Settings →
Layout & Display; stored in localStorage per media item since a
webtoon title and a paged manga volume want different flows).
Toggling reloads the reader — the renderer is chosen at open time —
and progress restores from the saved page. Relocate events flow
through the same pipeline, so the slider, progress saving, back
stack, tap zones, and edge zones all work unchanged. Zoom/fit/
magnifier/spread controls hide in webtoon (natural-width scroll).
- Display filters for fixed-layout: brightness (30-130%) and
contrast (70-130%) sliders with live preview, plus Night Mode
(invert) — also a quick row in the ⋯ tools menu. One --fx-filter
CSS var drives everything: ::part(filter) on foliate-view iframes
(forwarded via the new exportparts attribute) and the webtoon
page images alike. Persisted as fx_brightness/fx_contrast/fx_invert
(types + defaults both sides); Restore Defaults resets them.
Help menu (the reader had a growing shortcut/gesture vocabulary with
no discoverability): a ? topbar button, the '?' key, and F1 open a
glass modal listing navigation, zoom/pan, highlight, and touch
gesture reference — format-aware (fixed-layout/PDF rows appear only
where they apply), Esc closes it first in the dismiss chain.
Desktop edge zones: clickable page-turn strips on the left/right
viewport edges (8% width, 44-72px), desktop only (hover+fine-pointer
media query — touch devices use tap zones, avoiding double paging).
Hovering reveals a chevron arrow and a subtle edge gradient. Zones
disable (pointer-events pass-through) while a fixed-layout page is
zoomed so edge clicks belong to content: panning, selection,
highlight editing. fxZoomed tracks zoom state via the renderer zoom
event, reset/fit actions, and init.
Diagnosed in a real browser (playwright/chromium against the running
app + Head First SQL): the engine's book.toc held all 18 entries with
correct labels/hrefs and the tab counter even showed 380, yet zero
links rendered while the console flooded with 'Alpine Warning:
Duplicate key on x-for'.
Root cause: the drawer keyed TOC rows by item.href. PDF outline
entries frequently share the same destination (e.g. the printed TOC
page is targeted by several bookmark entries), so flattened items
carried duplicate keys — and Alpine's x-for renders NOTHING for a
duplicated key, not even the unique ones. EPUB TOCs never collided
because their hrefs are unique file paths, which is why this only
surfaced on PDFs.
Key is now href + row index (the list is static once loaded, so
positional keys are safe). Verified end-to-end in the browser: 18
entries render and the drawer populates.
Investigation: the contents drawer read book.toc, which makePDF
builds from pdf.getOutline() — verified against the real library PDF
(Head First SQL) through the exact vendored pdf.js build AND the exact
range transport the browser uses: 18 chapter entries come back. So
the source is right; manga-scan PDFs and CBZs simply have no embedded
outline, which made Contents look broken exactly where users expect
page-based navigation.
- TOC now populates eagerly right after the book opens (toggle-time
lazy population removed), so an existing outline can never silently
miss due to timing; the drawer keeps the honest empty-state text
for books without outlines.
- New 'Pages' tab in the contents drawer for fixed-layout books:
a Kavita-style thumbnail grid (3-up, current page highlighted and
scrolled into view, click to jump — recorded on the back-to-
location stack). Thumbnails render client-side: PDFs via the
in-memory pdf.js document (small viewport render, Alpine.raw
unwrap); comics via the page's image blob drawn down to a 110px
canvas, then unloading the full-size blob so thumbnailling doesn't
hoard page images. Lazy via IntersectionObserver scoped to the
drawer's scroll container (200px margin), canvases cached at module
level so revisits are instant; failures warn in console and allow
retry. The backend /readers/thumbnails endpoint turned out to be an
empty stub, so nothing server-side was worth wiring.
The display:none for the full toolbar lived in @layer components while
the div also carried Tailwind's flex utility (@layer utilities). Layer
order beats specificity, so the utilities layer always won and the
full bar never hid below the breakpoint (the compact row only worked
because it had no display utility of its own).
Switch to Tailwind's own responsive utilities in the markup — full
toolbar 'hidden md:flex', compact row 'flex md:hidden' — and delete
the custom rules; responsive display now resolves inside a single
layer where source order (responsive variants after base) guarantees
the right winner.
Wrapping alone isn't how polished mobile readers work. Adopt the
standard pattern (Kindle/Apple Books/Mihon) responsively:
- >= 768px: the full fixed-layout toolbar stays (wrap still absorbs
mid-size widths) — power users keep one-click zoom/fit/spread.
- < 768px: single-line compact row — page back, back-to-location pin,
slider, page forward, progress, and a ⋯ overflow button. No
wrapping, no horizontal scroll.
- ⋯ opens a glass menu anchored above the bar with LABELED rows
(Zoom −/%/+, Fit, Page position/Recenter, Magnifier, Pointer
Smart/Pan/Text, Double page, Contents) — labels beat mystery icons
on touch. Pointer row hides for comics; menu scrolls if tall.
- Dismissal: Esc, outside click (⋯ button exempt so it re-toggles
cleanly), opening any drawer or TOC closes it; hides with the
chrome. Compact slider registered in progressSliders() so all
three stay in sync with relocate events.
- Back-to-location moves from the topbar (where it sat between Back
and the title, too subtle and disconnected from navigation) into
both bottom-bar rows, beside the page-back arrow — the natural
'go back' cluster. New icon: a location pin, clearly distinct from
the back arrow and page controls. Appears only when the stack has
a return target; Alt+← unchanged.
- New recenter button in the fixed-layout row (crosshair icon, next
to zoom): resets pan offsets while keeping the current zoom —
backed by foliate's new recenter() (1c812e8), which zeroes the
wrapper translate and re-syncs the spread side.
- Both bottom-bar rows wrap gracefully on narrow windows instead of
overflowing/h-scrolling: controls are grouped (paging+back | slider |
fit+zoom+magnifier+recenter | pointer mode | spread | progress+TOC)
so groups flow to a second line at small widths; the slider shrinks
first (grow + min-width), everything else stays whole. Fixed-layout
row drops its overflow-x-auto.
PDF search diagnosis: extraction and matching were proven correct
against the real 609-page library PDF (pdfjs 5.5.207, incl. the exact
range-transport setup makePDF uses — 841 hits for 'SELECT'), and the
served bundle had every piece. The failure was Alpine's reactivity:
this.book is a plain object, so reading .pdf through component state
returns a reactive Proxy around the PDFDocumentProxy — and pdf.js
v5 uses #private fields, so getPage() through the proxy throws
'cannot read private member', which the empty catch rendered as a
silent empty result set. runPdfSearch now unwraps via Alpine.raw
(falls back to the raw read), and search failures surface in the
drawer ('Search failed — see console') plus console.warn instead of
masquerading as 'No matches'.
Back-to-location stack (research/footnote workflow): the current
position is recorded before every programmatic jump — search-result
clicks, TOC entries, bookmark and highlight jumps — and on every
internal link click (footnotes, cross-references) via foliate's
'link' event. A ↩ button appears in the topbar once a return target
exists; Alt+← works everywhere. Ordinary paging never pollutes the
stack (max depth 50, consecutive duplicates collapse).
PDFs have fully searchable text (pdf.js text layer) — the previous
reflowable-only gate existed only because foliate's generic search
needs DOM documents that PDF sections don't provide. This adds a PDF
pipeline alongside it:
- Fork d065495 exposes the pdf.js document proxy as book.pdf so the
host can drive text extraction directly.
- New web/src/reader/pdf-search.ts: extractPdfPages() pulls each
page's textContent with item geometry (progress-reported, cached
after first search). PDF text items often omit inter-word spaces
(gaps are positional), so pages are joined gap-aware — baseline
changes, hasEOL, or horizontal gaps past a font-size threshold
become spaces — recording a char→item map. searchPdfPages() does
case-insensitive matching over the joined text and maps each hit
back to the page-fraction rects of the items it spans, with
ellipsized pre/match/post excerpts. Pure functions, unit-sanity
checked (cross-item 'brave new' → two rects).
- runSearch branches: EPUB keeps foliate's DOM search; PDFs search
the extracted pages, group hits per page ('Page 12'), and render
on-page hit rectangles through the existing fraction-rect overlay
(addRectAnnotation) — which re-render automatically when pages
revisit, same as highlights. Clearing the query removes them.
- Results navigate by page index; the 🔍 button and '/' shortcut now
appear for PDFs too (comics remain without searchable text).
Wires foliate's search engine into the new drawer system:
- 🔍 topbar button (reflowable-only; PDF/comic sections have no
searchable text documents) and the '/' keyboard shortcut open a
Search drawer: query input (Enter to run), live progress while
scanning (per-section percent), match count, and results grouped
by section with TOC labels.
- Each result shows pre/match/post excerpt rendered as three text
nodes (no x-html — book content never enters the DOM as markup);
the match is styled with a translucent <mark>. Clicking jumps to
the hit's CFI and closes the drawer.
- Hits are drawn on the page through foliate's overlayer (outline
style) and persist across page turns — the engine re-applies
search results when a section's overlay is created. Clearing the
query removes the outlines.
- A generation counter discards results and progress from superseded
searches (rapid re-query), and starting a new search clears the
previous one server-side via view.clearSearch().
- Search integrates with the drawer system: scrim, Esc-to-close,
one-drawer-at-a-time, / focuses the input via .
Phase 3 (EPUB half) of the reader redesign:
- Select text in a reflowable book → floating glass popover at the
selection (5 colors, note, copy). Clicking a color creates the
highlight via POST /api/media-items/:id/highlights, anchored by the
foliate range CFI (epubcfi_start) with percentage position.
- Highlights render through foliate's overlayer pipeline: draw-
annotation draws Overlayer.highlight with the stored color,
create-overlay re-adds persisted highlights as sections load,
show-annotation opens the edit popover when a highlight is clicked
(recolor, edit note, copy, delete).
- Backend: highlight create/update accept epubcfi_start/end,
note_text, and percentage fields; position validation relaxed
(CFIs exceed the old 100-char cap); PUT routes through
AnnotationService.SaveHighlight so edits get dedup/LWW treatment
and actually persist note_text (the plain query can't).
- Bookmarks drawer becomes the Annotations drawer with tabs:
Highlights (color-bar list, note previews, jump/edit/delete),
Notes (add note at current position, list, delete — backed by the
existing notes API), and Bookmarks (unchanged behavior).
- Popover dismissed on outside click, collapsed selection, page
navigation, or Esc (new top-priority Esc branch).