Commit Graph
67 Commits
Author SHA1 Message Date
John O'Keefe b89eac22e3 fix(templ): stop emitting literal { expr } text into attributes and body classes
Release / build-and-push (push) Successful in 3m31s
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.
2026-10-18 12:08:20 -04:00
John O'Keefe 73ae1789c5 feat(reader): fullscreen mode; revert stacked volume; fix manga badges + ghost sidebar
Reader fullscreen: the topbar gains a fullscreen toggle (icon state + f
shortcut + help row; f is guarded so Ctrl/Cmd+F stays find-in-page).
Enter requests the Fullscreen API with fallbacks (desktop/Android get a
true full display; iOS Safari has no page-fullscreen API and falls back
to the soft visual mode only). Margins: the foliate paginator's closed
shadow root only yields through its observed 'margin' attribute, so
fullscreen fits a comfortable ratio (6% of the short side, floored at
48px) and floors it by the measured env insets — Android reports true
cutout insets in fullscreen, so portrait phones keep text clear of the
camera band; iOS letterboxes are already env-padded chrome. Fixed-layout
renderers skip it (fxl/webtoon pack pages edge-to-edge by design).
Exits the user makes outside the toggle (Esc, browser UI) sync via
fullscreenchange(+webkit) and restore the ordinary 48px margin; a
position-keeping re-render follows the ResizeObserver re-entry path.
Verified on-device: enter -> true fullscreen + icon swap + camera band
clear, exit -> chrome back, margins restored, position kept.

Volume: revert the stacked panel — the popup returns to the liked shape
(mute button left, horizontal dragger right, dialogue above the strip);
one-line flex restore of the pre-89665fb layout.

Manga badges: MediaBadgeKind makes the library type authoritative for
the comic/manga split — manga volumes ship as fixed_layout scans as
often as comic_archive (and manga_type is unreliable across sources:
unknown/NULL on real rows), so the old comic_archive+manga mapping left
the scan volumes unbadged. Anything in a manga library reads manga;
anything in a comics library reads comic. Verified: /dashboard lands 24
title="Manga" cover badges for the Manga library and the sections API
reports badge=manga for a fixed_layout item.

Ghost sidebar: the >=1024px media block applied body
padding-left:var(--sidebar-w) unconditionally, so pages that never
render the sidebar (logged-out pages, the audio player, the reader
itself) carried a phantom 16rem left margin that pushed content off
center on desktop widths. The body:has(.app-sidebar) rule below was
already the correct conditional source — it is now the only one.
Verified in the built stylesheet: exactly one such rule remains.

Infra: main.js ?v=3 stamp bump (reader.ts changed; hashed chunks churn
their URLs themselves).
2026-10-15 20:27:37 -04:00
John O'Keefe 431fec05ad feat(security): library visibility enforced at the source; per-library series; annotation ownership; admin user creation
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.
2026-10-13 12:03:01 -04:00
John O'Keefe 4d1fb05e49 feat(web): PDF highlight extent adjustment — rect-edge grips with word-token snap
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.
2026-10-10 11:22:09 -04:00
John O'Keefe 31f939002f feat(web): 'a' keyboard shortcut toggles the annotations drawer
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.
2026-10-08 15:12:27 -04:00
John O'Keefe bbecd1e5ec fix(web): rich markdown notes get block structure — paragraphs, quotes, lists, code
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.
2026-10-07 08:01:41 -04:00
John O'Keefe 5275f2ba38 feat(web): note editing at the reader slider and book banner; Deleted rename
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.
2026-10-05 11:36:14 -04:00
John O'Keefe 0b83db8fce feat(web): book-detail annotations banner — ?loc= reader jumps, single/zip markdown export
Release / build-and-push (push) Successful in 3m9s
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).
2026-10-04 14:30:56 -04:00
John O'Keefe 66dd6c8096 feat(reader): note length counter and save guards in the reader UI
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.
2026-10-02 21:31:13 -04:00
John O'Keefe 9e4cc4efd0 chore(templates): regenerate templ outputs and rebuilt static assets
Release / build-and-push (push) Successful in 2m42s
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).
2026-09-21 18:10:47 -04:00
john-okeefe 80e5d5b0d1 feat(reader): comic treatment for fixed-layout items in comics/manga libraries
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
2026-09-17 14:36:58 -04:00
John O'Keefe b3a429d7e1 fix(reader): Save/Cancel in the note editor closes the whole popover
Release / build-and-push (push) Successful in 2m46s
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.
2026-09-17 11:47:25 -04:00
John O'Keefe d8179bb066 fix(reader): popover dismiss on PDF taps; notes save on create; colors don't close editor
- 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).
2026-09-17 10:55:21 -04:00
john-okeefe ce3ae31ced fix(reader): read position from the API at open; never write a restored position
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.
2026-09-09 14:52:30 -04:00
john-okeefe e500039d1b feat(reader): webtoon reading mode + brightness/contrast/night filters
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.
2026-08-18 08:25:00 -04:00
john-okeefe 94be6edceb feat(reader): shortcuts help modal + desktop edge page-turn zones
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.
2026-08-18 07:59:26 -04:00
john-okeefe 206db93587 fix(reader): PDF contents drawer rendered nothing — duplicate x-for keys
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.
2026-08-17 14:14:33 -04:00
john-okeefe fd4c357d39 feat(reader): page thumbnails tab + reliable PDF contents
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.
2026-08-17 13:55:21 -04:00
john-okeefe dc68d03360 fix(reader): both toolbars showed below 768px — cascade-layer conflict
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.
2026-08-17 13:42:02 -04:00
john-okeefe 6cd0fb226a feat(reader): mobile-pattern toolbar — compact row + ⋯ overflow menu under 768px
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.
2026-08-17 13:35:14 -04:00
john-okeefe f283903e2b feat(reader): relocate back-to-location, add recenter control, wrap bottom bar on small windows
- 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.
2026-08-17 13:23:59 -04:00
john-okeefe 1905feceea fix(reader): PDF search returned nothing — reactive proxy broke pdf.js; add back-to-location
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).
2026-08-17 08:23:57 -04:00
john-okeefe 6fc4107e3c feat(reader): in-book search for PDFs
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).
2026-08-17 08:04:30 -04:00
john-okeefe 5e73b0a4f6 feat(reader): in-book search for reflowable formats
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 .
2026-08-17 07:58:03 -04:00
john-okeefe 40d70513da feat(reader): EPUB highlights & notes — selection popover, overlayer rendering, annotations drawer
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).
2026-08-16 12:33:55 -04:00
john-okeefe 24ea9d8a38 feat(reader): touch & mobile — tap zones, gesture engine, mobile sheets
Phase 2 of the reader redesign:

- Fixed-layout touch engine (foliate-js e9e61d8): pinch-zoom around
  the midpoint, two-finger pan, single-finger pan while zoomed,
  horizontal swipe page-turn at fit (RTL-aware via next()/prev()),
  and double-tap to zoom 2.5x / reset. Touch events forwarded from
  page iframes with converted coordinates; preventDefault only when
  the engine consumes the gesture, so PDF text selection and native
  taps stay intact. touch-action: none on the host and in comic/pdf
  page documents keeps the browser from fighting the engine.
- Tap zones (Kindle-style) for touch devices: tap the outer margins
  to page, center to toggle chrome. Size configurable (10-50%) via
  the revived tap_zone_size setting; toggle via new tap_zones_enabled
  (Behavior section of the settings drawer). Pointer-based + passive
  so drags/swipes/selection never trigger; attached both to the
  viewport and inside every page document (iframe events don't
  bubble); debounced 280ms so double-tap zoom doesn't also page; no
  zone actions while a fixed-layout page is zoomed.
- Drawers become full-width sheets on screens <= 640px.
2026-08-14 16:00:19 -04:00
john-okeefe 14d1a158a0 feat(reader): glass chrome — translucent bars, custom slider, slide-away hide
Modernize the reader chrome bars without touching the drawer system:

- Bars become theme-tinted glass: 70% bg-primary translucency over
  the edge-to-edge page, 18px backdrop blur + saturation, hairline
  translucent borders, soft directional shadows (single .reader-glass
  class owns the effect; replaces solid opaque backgrounds and the
  tailwind backdrop-blur that would override it).
- Chrome hide/show now slides the bars off-screen (translateY) in
  addition to the opacity fade, via .chrome-hidden on #reader-chrome.
- Theme-aware hover pills (translucent currentColor tint) replace
  hard-coded gray-700 hovers; focus-visible rings added.
- Progress slider gets a custom thin rounded track with a floating
  white thumb (webkit + gecko), replacing native range styling.
- Separators and the fit-mode select match the glass language
  (.reader-sep, .reader-select).
2026-08-14 15:16:05 -04:00
john-okeefe 612f888683 feat(reader): immersive chrome, slide-over drawers, tri-state PDF pointer mode
Phase 1 of the reader redesign:

- Reading surface is edge-to-edge; top/bottom bars overlay
  translucently (backdrop-blur) instead of reserving insets, killing
  the inset-coordination bug class entirely. Chrome auto-hides after
  2.5s of pointer inactivity (chrome_behavior setting finally wired:
  auto-hide / always-visible; legacy values map to auto-hide). Pointer
  activity inside page iframes keeps it awake; Esc toggles.
- TOC / Settings / Bookmarks become slide-over drawers with a scrim
  (z-50, full-height, safe-area aware), replacing the dockable-panel
  system and its window-shade headers. Only one drawer opens at a
  time; Esc or scrim click closes.
- Bottom bar is contextual: reflowable keeps nav/slider/progress/TOC;
  fixed-layout row adds Fit Page/Width select, zoom cluster,
  magnifier (now shows active state), Double Page Spread toggle, and
  a Smart | Pan | Text segmented control replacing the cryptic
  two-state icon. Smart = text-aware drag; Text = selection-only
  (manual smart-detect off); Pan = force pan. Choice persists via
  pdf_interaction_mode (new setting + foliate 29bc958 'text' mode).
- Settings drawer: Behavior (chrome, progress mode), Appearance with
  18 Kindle-style theme swatches (single source of truth from
  THEME_COLORS), Typography, Layout — each scoped by format.
- Keyboard: t/s/b open TOC/settings/bookmark, Esc closes drawers
  before toggling chrome, shortcuts skip form inputs; both slider
  rows tracked correctly (no duplicate-ID lookups).
- Topbar: Back, title, add-bookmark, bookmarks drawer, Aa settings;
  chrome follows user theme.
2026-08-14 14:59:51 -04:00
john-okeefe ba95cc3e8b fix(reader): stabilize chrome panels, bookmarks end-to-end, dead UI removal
Phase 0 of the reader redesign:

- Panels no longer render under the top/bottom bars: sidebars get
  measured insets (same resize/safe-area mechanism as the viewport);
  panel max-height now derives from the bounded sidebar instead of a
  100vh guess; right-side border targets the actual sidebar.
- Bookmarks work end-to-end for the first time: REST CRUD under
  /api/media-items/:id/bookmarks (create/delete route through
  AnnotationService for dedup/LWW/tombstones), fix UpdateMediaBookmark
  referencing nonexistent updated_at column, frontend posts to the
  real API with per-format position (CFI vs page), live list with
  jump + delete instead of SSR-only snapshot.
- Fix chapter matching in progress saves: boundaries were compared by
  a nonexistent tocItem property, so chapter was never persisted.
- Remove dead UI: Navigator panel stub, empty dictionary popup shell,
  unwired Chrome Behavior select; purge 160 stale build artifacts.
- Reader chrome now follows the user's app theme instead of hardcoded
  theme-tokyo-night.
2026-08-14 09:05:33 -04:00
john-okeefe f5d9578375 feat(reader): wire double-page spread setting into web reader
The double_page_spread checkbox in the reader settings panel was inert:
it had no Alpine binding, no apply logic, and no persistence. Default
was also inconsistent (false in settings-manager, absent from server
defaults).

- Add doublePageSpread state to the reader Alpine component, loaded
  from saved settings (default true)
- Add applyDoublePageSpread() which sets the renderer's 'spread'
  attribute to auto/none and persists the setting via saveSettings
- Apply the spread attribute during fixed-layout renderer init
- Bind the settings checkbox with x-model and @change
- Add double_page_spread: true to ReaderService server defaults so
  new users get the same starting value the client expects
- Also improve the PDF pan/select toolbar button: distinct smart-
  select vs pan icons, highlighted state while pan mode is active,
  and dynamic tooltips/aria-labels explaining each mode
2026-08-14 08:17:25 -04:00
john-okeefe ab465d8e0e feat(ui): adopt book-open brand icon and add SVG favicon
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.
2026-08-10 13:43:13 -04:00
john-okeefe 73a2852ee3 fix(reader): back button remembers the page you came from
The reader's back button was hardcoded to the book detail page
(/media/{id}), so even when you launched the reader straight from the
dashboard the back button ignored that and sent you to the detail view.

Mirror the existing book-detail.ts referrer pattern: capture
document.referrer into sessionStorage on load (excluding other reader
pages and the reader's own URL), then override the back link's click to
navigate there. Falls back to the link's original href (/media/{id}) when
no valid referrer exists (direct URL access).
2026-08-06 09:51:04 -04:00
john-okeefe 987ece38f0 fix(reader): keep footer compact on mobile and clear text under chrome
On phones the footer's progress cell rendered the full chapter title (e.g. 'Long Chapter Name · 5 / 12') in a div with no max-width or nowrap, so the text wrapped to multiple lines and ballooned the bottom bar. Combined with viewport offsets that were computed from assumed pixel heights with ~0px margin, the book text slipped underneath the bars.

Chapter label in footer: split #progress-display into two spans (progressLabel hidden on mobile via 'hidden sm:inline', progressMain always shown) and cap it with 'truncate whitespace-nowrap max-w-[5rem] sm:max-w-none' so it can never wrap or grow the bar. Phones now show just '5 / 12'; larger screens keep 'Chapter · 5 / 12'.

Viewport offset: replace the fragile hardcoded calc() with runtime measurement. Gave the chrome bars ids (reader-topbar/reader-bottombar) and added updateViewportInsets(), which sets #reader-viewport top/bottom from each bar's real offsetHeight (which already includes env(safe-area-inset-*) padding) plus a 6px margin. It runs on init and refreshes on resize, orientationchange, and via a ResizeObserver, so the content area tracks the actual chrome height on any DPI, notch, home-indicator, or zoom level instead of guessing.

Refactored formatProgress into formatProgressParts (returns {label, main}; only chapter mode sets a label) with a setProgress() helper wiring progressLabel/progressMain/progressText across the relocate, cycleProgressMode, and applyProgressMode call sites. Rebuilt reader_templ.go and style.css.
2026-08-05 21:58:42 -04:00
john-okeefe 11617c1860 fix(reader): tighten mobile header/footer and add safe-area margins
The reader chrome used the same dimensions at every screen size, and the book viewport offset was hardcoded to 52px. This made the header/footer oversized on phones and left the body text flush against (or overlapping) the bars, with no handling for notched-device safe areas.

- Add viewport-fit=cover so notched devices expose safe-area insets.
- Shrink the top bar on mobile (px-3 py-2 / text-base, scaling up at sm:) and hide the chapter title on phones (hidden sm:block sm:truncate).
- Shrink the bottom bar on mobile (tighter padding/gap, p-1.5 sm:p-2 on buttons) while keeping all controls visible.
- Replace the hardcoded top-[52px] bottom-[52px] viewport offsets with responsive calc() values (44/48px mobile, 60px at sm:) that fold in env(safe-area-inset-*), plus matching safe-area padding on the bars, so the book content always clears the chrome with a visible margin.

Regenerates reader_templ.go and rebuilds style.css.
2026-08-05 15:31:40 -04:00
john-okeefe 423a1c0bf7 feat(sync): use page index as canonical locator for fixed-layout & comic formats
Fixed-layout EPUBs, PDFs, DjVu, and comic archives (cbz/cbr/cb7/cbt)
are page-based: each page is a fixed image, so a page index is an exact,
universal locator regardless of screen size or device. Sync previously
treated these like reflowable content (CFI-first restore, percentage
fallback, character-offset math), which was both wrong and lossy. This
makes the page index the canonical position for fixed-layout and comic
formats while leaving the reflowable path byte-for-byte unchanged.

Backend:
- progress.go SaveProgress: branch on mediaItem.FormatGroup. For
  fixed_layout/comic_archive derive percentage from current_page/
  total_pages and skip the CFI/character-offset back-fills (meaningless
  for image content). The reflowable derivation block is preserved
  verbatim under an else.
- kobo.go: Kobo only sends a percentage, so for fixed-layout/comic
  formats derive CurrentPage via PercentageToPage(percentage, pageCount)
  using the media item's known page count, so Kobo->web lands on the
  exact page.

Reader:
- reader.templ readerInitExpr: pass savedPage/savedTotalPages to the
  reader config for fixed_layout/comic_archive formats.
- reader.ts: when isFixedLayout and savedPage is present, restore via
  view.init({ lastLocation: savedPage - 1 }) (a bare number navigates
  foliate directly to the section index). Reflowable falls through to
  the existing CFI->percentage path, unchanged.
2026-06-07 00:03:46 -04:00
john-okeefe f57d64563b fix(reader): scale percentage correctly, add 5s init guard
- reader.templ: divide DB percentage (0-1) by 100 was wrong; actually
  the router multiplies by 100, so template now divides by 100 to get
  back to 0-1 range for foliate-js savedPercentage
- reader.ts: add 5-second initTime guard to prevent overwriting
  existing progress with initial position on page load
2026-06-02 19:46:03 -04:00
john-okeefe 13260d8c1f chore(templates): regenerate all _templ.go files with templ v0.3.1020
Upgrade from templ v0.3.1001 to v0.3.1020. Generated code changes include
JoinStringErrs -> ResolveAttributeValue and removal of manual EscapeString
calls (now handled internally by ResolveAttributeValue).
2026-05-24 21:20:24 -04:00
john-okeefe 0a9cde0fc8 chore(templates): regenerate all _templ.go files
Regenerate all templ-generated Go files. These changes are caused
by running templ generate with a slightly different CLI version
(v0.3.1001) than the go.mod dependency (v0.3.1020), resulting in
minor formatting/import diffs across all templates. No functional
changes.
2026-05-18 17:53:12 -04:00
john-okeefe f23ee6e66a chore(templates): regenerate all templ Go files, add scan spinner to header
Regenerated templ output for all template files. Key source change:
- templates/header.templ: add scan progress spinner SVG and percentage
  display to header nav, initialize scan listener via x-init
2026-05-16 19:31:52 -04:00
john-okeefe 4b43bd04ba chore: regenerate templ files for v0.3.1001
Reverts generated Go template files from templ v0.3.1020 back to
v0.3.1001 output. Changes include filename path prefix adjustments
(admin_library.templ → templates/admin_library.templ) and attribute
handling differences (ResolveAttributeValue → JoinStringErrs + EscapeString).
2026-05-12 16:54:14 -04:00
john-okeefe b829b7fe22 chore: regenerate all templ Go files for v0.3.1020
templ v0.3.1020 generates different code than v0.3.1001 (uses
ResolveAttributeValue for attribute handling). Regenerated all 33
_templ.go files to match the new runtime.
2026-05-10 16:13:53 -04:00
john-okeefe ffaa561cb7 chore: regenerate all templ Go files
Regenerated from .templ sources after template changes. Includes
path reference updates in error messages (templates/ prefix
shortened) from templ tool regeneration.
2026-04-29 20:33:08 -04:00
john-okeefe cb214b16b2 feat(reader): send richer progress payload with chapter boundaries and zoom
The reader's saveProgress() now sends a more complete payload to the
backend so ProgressService has more data for enrichment and merge:

- chapter: computed from TOC boundary index instead of missing
- reading_mode: current display mode (page, chapter, percent, time-left)
- zoom_level: for fixed-layout books (renderer.zoomPercent / 100)
- current_page: real page number for fixed-layout, location.current for
  reflowable
- total_pages: section count for fixed-layout, location.total for
  reflowable

Adds computeChapterPageBoundaries(doc) for reflowable EPUBs that maps
TOC anchors to rendered page numbers, recomputes after fonts load.
Adds computeFixedLayoutChapterBoundaries() for fixed-layout books that
resolves TOC hrefs to page indices via view.resolveNavigation().

Updates reader.templ to expose isFixedLayout to Alpine init.
2026-04-25 21:16:52 -04:00
john-okeefe d68f72f21b feat(reader): wire up progress mode switching with four display modes
Connect the existing progress_mode setting dropdown to the reader's
progress display. Four modes are now functional:
- pages: overall percent + page/location number (default, existing)
- chapter: chapter title + page X / Y within current section
- percentage: overall percent only
- time-left: percent + estimated time remaining via reading speed API
The progress display in the bottom bar is now clickable to cycle through
modes with immediate visual feedback. The settings dropdown is bound
with x-model for persistence. Reading speed is fetched once on init
from the backend reading-speed API for time-left estimates.
2026-04-25 13:40:18 -04:00
john-okeefe d38804e910 fix(progress): correct percentage display and add format-aware progress
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
2026-04-24 14:03:03 -04:00
john-okeefe 0269403a5d feat(reader): wire up reading progress save and restore
The web reader had all the infrastructure for progress persistence
(updateReadingProgress/getReadingProgress API functions, PUT/GET
endpoints, database queries) but the reader.ts never called them.

Changes:
- Add debounced (2s) saveProgress call on every relocate event that
  PUTs percentage, current_page, total_pages, and epubcfi to the
  existing /api/media-items/:id/progress endpoint
- Replace renderer.next() with view.init({ lastLocation }) to restore
  the reader to the last saved position on load (CFI first, then
  fraction fallback, then default first page)
- Pass savedPercentage and savedCfi from server-side progress data
  through readerInitExpr config to the JS initReader function
- Add mediaItemId and saveTimeout to the Alpine data object

This fixes both the blank /progress page and the missing progress
section on book detail pages — both were empty because the
reading_progress table never received any data from the web reader.
2026-04-23 21:08:30 -04:00
john-okeefe 35c8ffe33e fix(reader): URL-encode file paths and JSON-encode init config to fix comics/manga loading
The reader failed to load comics and manga (and any file with special
characters in its path) for two reasons:

1. FileURL was built with raw fmt.Sprintf instead of ResolveMediaURL,
   so characters like '#' in paths (e.g. 'Annual #2') were interpreted
   as URL fragments, truncating the path and causing 404s.

2. The Alpine x-init expression used raw string interpolation for config
   values, so apostrophes in paths (e.g. "I'll Use My Appraisal Skill")
   broke JavaScript parsing with 'Unexpected identifier'.

Fix by using utils.ResolveMediaURL for proper URL path encoding and
json.Marshal for the initReader config to safely escape all special
characters.
2026-04-23 20:39:36 -04:00
john-okeefe 4119a5e38e fix(reader): use proper templ expression for back link href and clean up formatting
The back link in ReaderChrome used literal curly braces inside the href
attribute string (href="/media-items/{ metadata.MediaItemID }") which
doesn't interpolate the variable in templ. Changed to use the correct
templ expression syntax: href={ "/media/" + metadata.MediaItemID }.

Also fixed minor formatting issues:
- Normalize whitespace in comment after closing div
- Collapse empty navigator-viewport div to single line
2026-04-23 17:01:01 -04:00
john-okeefe ba502ec750 refactor(reader): remove test font, move settings panel to right sidebar
- Remove Playwrite NZ Guides test font and all references (FONT_MAP,
  FONT_FILES, reader-fonts.css, dropdown option, font files)
- Move settings panel from left sidebar to right sidebar (left sidebar
  now only contains TOC)
- Move Restore Defaults button from top bar icon to a styled button
  inside the settings panel, side by side with Done button
2026-04-19 21:42:19 -04:00
john-okeefe 9ca8e2aa44 fix(reader): use blob URLs for fonts to bypass sandboxed iframe restrictions
The paginator renders inside a sandboxed iframe that blocks @font-face
URL fetches. Fonts were never loading — all font-family rules fell back
to the generic 'serif' system font, making every reading font identical.

Fix: fetch font files on the parent page, create blob: URLs via
URL.createObjectURL(), and use those blob URLs in the @font-face rules
injected into the iframe via setStyles(). Blob URLs are always
same-origin with the creating document, so the sandboxed iframe can
access them with allow-same-origin.

Also added Playwrite NZ Guides as a test font for verifying font
switching works.
2026-04-19 21:36:19 -04:00