Compare commits

..
32 Commits
Author SHA1 Message Date
John O'Keefe 795225f097 fix(scanner): reconcile archive pass per library, not per folder
Release / build-and-push (push) Successful in 3m5s
The archive pass looped over the scan's folders and, inside each
iteration, compared the ENTIRE library's item list against that one
folder's walk. With two registered folders every item living under the
other folder was flagged "missing (1/2)" on every reconciling scan —
prod's audiobook library (the only multi-folder one) showed 743/743
flagged — and nothing reached the two-consecutive-miss threshold only
because each scan's walk clears missing_scan_count before the pass
re-flags it. With three or more folders the defect stops bouncing: a
later iteration sees an earlier iteration's count-1 marks on items its
own walk lacks and archives them mid-pass — one registered folder away
from archiving a third of a library per scan. Single-folder libraries
were unaffected, which is why comics/ebooks/manga showed zero flags.

Folder-book presence was suspected alongside it (audioBookRoots read
during the comparison), but the map is written for every >=2-track
folder-book root on every walk (processAudioBookFile, since 6216bd9b);
the flagged folder-books were victims of the same cross-folder
comparison. The roots attribution did carry the same unchecked
strings.HasPrefix boundary bug as the rest of the scanner (8th site,
variable named `root`, missed by the earlier sweep); it now uses
folderContains.

The pass now groups folders by owning library and, per library,
compares ListMediaItemsByLibraryIncludingArchived against the UNION of
all that library's folders' walks. The coverage (partial-scope) guard,
the walk-failure skip, and the mark/archive/purge item loop are
unchanged.

Regression test (archive_multifolder_test.go) builds a two-folder
audiobooks library — single-file book in one folder, folder-of-tracks
book in the other — and scans twice: pre-fix both items are flagged
missing on both scans (verified by running the test against the old
code), post-fix neither is ever flagged or archived.
2026-10-28 01:30:41 -04:00
John O'Keefe 9f39e2efd8 feat(settings): save feedback — success/error toasts, disabled buttons; fix scan-form raw-JSON swap
Release / build-and-push (push) Successful in 4m35s
None of the three save surfaces on the admin settings page gave usable
feedback: the Base URL form re-rendered into an identical-looking form
(its only indicator was the Go-source twin's banner — markup that
diverged from the templ original), the scan form was WORSE — its handler
responds JSON, so htmx's outerHTML swap replaced the whole scan card
with literal JSON text — and the tunable rows showed only a tiny
per-row label.

Client side (toasts are the sanctioned client-local island; no new
inline scripts, CSP unchanged):

- web/src/settings.ts: one delegated htmx:afterRequest/sendError
  listener keyed on a data-settings-save attribute stamped on each
  form. Success → green toast per surface ("Settings saved" / "Scan
  settings saved" / "Setting saved"); failure → error toast carrying
  the server's JSON error field, degrading to a status line for the
  tunable endpoint's HTML error body (markup must never reach a toast).
  Network-level failures (htmx:sendError) toast instead of silently
  re-enabling the button.
- htmx 2.x quirk handled: for FORM submissions, afterRequest's
  detail.elt is the event-dispatch ancestor, not the form — the
  requesting element lives on detail.requestConfig.elt (verified
  empirically; saveLabelFor reads the right one).
- hx-disabled-elt="this" on all save buttons: the click is
  acknowledged and double-clicks cannot double-save.

Server side:

- Scan form: hx-swap="none" (the inputs already hold the saved values;
  nothing needs re-rendering) — kills the JSON-over-the-card swap.
- The Base URL form's save response is a Go-source copy of the templ
  form (handlers cannot import templates: templates/utils.go imports
  handlers types). The twin now carries the functional attributes
  (data-settings-save, hx-disabled-elt) so feedback survives the swap,
  with a drift-warning comment; the double-save e2e pins this.

Dev-DB safety: every success save in the spec re-submits the value
already on the page (zero drift), and the spec restores
system_config.updated_by after itself — the save handler stamps the
requesting admin's id there, and that FK (no cascade) otherwise blocks
the integration harness's transient-user sweep (it broke
TestUpdateProfileAdminMode/TestDeleteUserConsolidated on this run's
first pass; pollution cleaned up by hand and now self-restoring).

Tests: web/src/settings.test.ts pins the form→label map and the
error-extraction contract (including the no-markup-in-toast rule);
e2e/settings-feedback.spec.ts drives all three surfaces through the
real admin UI including the error path and the twin-drift double-save.
2026-10-28 00:22:00 -04:00
John O'Keefe 7b9e8e4fc2 fix(scanner): match library folders only at directory boundaries
A bare string-prefix test (strings.CutPrefix / strings.HasPrefix) let a
registered folder whose path was a string prefix of a sibling claim the
sibling's files: "/app/audiobooks" matched "/app/audiobookssd/Brian
Jacques/...", so getRelativePath sliced the remnant "sd/" into every
stored relative path for that library (prod scan log: "[AUDIO] Created
folder book 'Taggerung' (41 tracks) at sd/Brian Jacques/Redwall/Book 14").
Scanner lookups built their keys with the same function
(getMediaItemByFilePath, scanStateLookup), so writes and lookups were
consistently poisoned and no scan ever corrected them; everything
outside the scanner resolves paths by joining back to the roots
(ResolveMediaPath), so cover/track URLs 404ed for files sitting one
directory away.

The same unchecked HasPrefix pattern also drove five folder-membership
sites, so a sibling-prefix pair misattributed more than paths:
owning-library resolution (processMediaFile, maybeProcessAudioBook),
per-library-type extension allowlisting (isScannableFile), and
folder-structure metadata extraction on both create and update paths —
a file could be scanned under the wrong library's rules entirely.

folderContains(folder, path) is now the single boundary-aware
primitive: path must equal the folder or continue with a
filepath.Separator (after Clean). All seven sites go through it, so no
folder name — however similar to another — can interfere with any
other's files again, in any order of registration; trailing slashes on
registered folders are normalized. Registration order still decides for
genuinely overlapping (nested) registered roots: pre-existing behavior,
deliberately unchanged.

Regression tests (media_scanner_path_test.go, pure unit — no DB) pin the
prod trap (audiobooks/audiobookssd), order-independence, trailing
slashes, mid-word siblings, and the helper itself.

Repair of already-poisoned rows is deliberately separate and safe only
under this fix: a rescan repoints them via the SHA-256 move path
(MoveMediaItemFilePath) once lookups stop producing poisoned keys.
2026-10-27 23:44:41 -04:00
John O'Keefe 4c43caa639 fix(search): dropdown dead on htmx-less pages; Alpine-side debounce; auto bundle stamps
The "top search bar doesn't work" report had TWO stacked causes, found
by reproducing with real keypress-by-keypress typing instead of
Playwright's fill() (which fires a single input event and masked both):

1. htmx.min.js was included PER PAGE. The header's live search is
   htmx-driven on every page, but five pages rendering @Header —
   series, admin, admin hash-conflicts, admin processing-issues,
   browse-detail — never loaded it: the input's hx-* attributes were
   inert (htmx-internal-data absent), so typing produced no request at
   all. The include now lives in the header partial itself (classic
   script, before the module bundle); the sixteen per-page copies on
   @Header pages are removed (double-processing guard). Non-header
   pages (login/register/setup/reader/index) keep their own.

2. htmx's delay: modifier on a PROGRAMMATICALLY dispatched custom event
   (htmx.trigger) loses the request entirely under rapid successive
   triggers — every re-fire canceled the pending timer and the last
   never issued, so fast typing (the normal kind) never opened the
   dropdown while one-shot fill() worked. The debounce moved into the
   Alpine component (client-local state, where it belongs); htmx now
   executes the bare `search` event immediately.

Also made bundle staleness structurally impossible: each bundle's
script stamp is a content hash computed at startup (templates/
bundles.go), so a rebuild that changes bundle bytes changes its cache
key with no manual ?v=N bump — the manual stamp was how the first
dropdown fix stayed invisible for a deploy. The reader bundle (never
stamped at all) is covered too. HTML pages additionally send
Cache-Control: no-cache (they are dynamic), so a stale page can no
longer reference a stale stamp; /api, /static and /uploads keep their
native caching, pinned by a middleware test.
2026-10-27 23:33:52 -04:00
John O'Keefe fd9292e6f2 fix(series): Date Added respects the direction toggle; bump bundle stamp
Two follow-ups from live testing:

- "recent" is BOTH a base toolbar key and a full registry key (where it
  means desc). ResolveSeriesSort's full-key passthrough ran first, so
  sort=recent&dir=asc silently resolved back to desc — the direction
  chevron flipped but Date Added could never sort ascending. "recent"
  now composes with the direction before the passthrough, unknown bases
  fall to the default entirely (a hostile dir can't ride an unknown
  sort), and the composition table is unit-pinned plus an HTTP-level
  row in the series filter test.

- The header search "still broken" report was a stale-cache artifact:
  the fixed code shipped but main.js kept its ?v=7 stamp, so browsers
  with a cached copy never refetched (fresh-profile e2e passed while
  the user's browser didn't — heuristically cached module). Stamp bumped
  to ?v=8 in header + docs; the e2e suite re-verified against the
  rebuilt server.
2026-10-27 22:55:11 -04:00
John O'Keefe ffbcc53083 test(e2e): drive write-to-file per format in the real UI; fix sidecar perms
Per the owner's testing bar: a Playwright spec registers an admin,
seeds one fixture per write-capable type (EPUB, PDF, CBZ, MP3, FLAC,
M4A, M4B — generated at runtime, no binary media in git), drives the
REAL metadata-editor flow (Edit → change title → in-app dialog →
Save & write) for each, and then reads every artifact back
INDEPENDENTLY of the app — EPUB/CBZ/sidecar via python zip+XML parse,
audio via ffprobe — asserting the written title landed in the FILE and
the old one is gone.

Writing the e2e exposed a real bug: atomicWrite left server-written
sidecars at 0600 root (os.CreateTemp's default), so host-side tools
sharing the volume — Calibre reading metadata.opf — were locked out.
The temp file is now chmod'd 0644 before the rename, matching the
scanner's cover-file convention; the e2e's host-side reads prove it.
2026-10-27 22:26:00 -04:00
John O'Keefe fb1645c90c fix(metadata): m4b text-track fallback; in-app confirm; save-first write
Three defects from real-world use:

- The m4b rewrite assumed every stream survives -map 0 -c copy; files
  carrying text-ish tracks (the movenc 'text' fourcc) fail the ipod
  muxer with "Tag text incompatible with output codec id" — exactly the
  error a write attempt hit in the field. The rewrite now attempts full
  fidelity first, then retries mapping audio + attached pictures only
  (-dn -sn): chapters live in the container header (-map_chapters 0)
  so they survive; every attempt still ffprobe-validates before the
  atomic rename and the original is never touched on total failure.

- The browser confirm() is replaced by an in-app dialog
  (WriteToFileConfirmDialog, Alpine + templ — no native dialogs).

- "Write to file" now SAVES the pending editor edits first (persisted
  through the same PUT the Save button uses), then writes the file
  from those DB values, and closes the editor + refreshes the page
  exactly like Save — previously the endpoint wrote the LAST-SAVED DB
  values, so edits sitting in the modal never reached the file, and
  the page didn't visibly update.

Test coverage per the owner's bar: EVERY single-file container (MP3,
FLAC, M4A, M4B) gets its own runtime-generated fixture, gets written,
and the FILE's metadata is then verified independently — ffprobe reads
the rewritten container and every mapped key is asserted per format,
including the subtitle round-trip split (MP3/FLAC yes, M4A/B no) — and
the EPUB/CBZ/sidecar assertions now PARSE the written XML (dc titles/
creators, ComicInfo fields, calibre meta) instead of substring
matching. A smart-genre unit test also pins the mixed-blob contract: a
recognized shelf wins the genre column even when demoted format words
ride along in the same blob.
2026-10-27 19:05:23 -04:00
John O'Keefe 96c1228805 feat(series): multi-field search, sort-direction toggle, apply placement
Toolbar feedback round: the series search box now matches whatever the
main search matches for a group — series NAME, any book's AUTHOR, and
any book's TITLE inside the series (WHERE EXISTS per group-row, so
counts stay true); the separate author input is gone from the toolbar
(the author= param stays live for the JSON API and deep links). The
sort control becomes a base-key select (Date added/Name/Author/Books/
Progress/Last read) plus a chevron direction toggle that flips and
resubmits; sort/dir compose through ResolveSeriesSort (full registry
keys keep working for back-compat, unknown base keys fall to recent).
The search/apply button moved next to the search box as requested, and
default URLs stay clean (sort/dir only emitted once the user chooses).
2026-10-27 19:05:23 -04:00
John O'Keefe 14285ff314 fix(search): register the headerSearch Alpine component (dropdown regression)
The dropdown never opened on typing: the search-dropdown migration left
./search un-imported after dropping the initializeSearch call from
header.ts, so the bundler tree-shook the module away and the
x-data="headerSearch" scope never registered — every handler binding
(input/focus/keys) silently died. This is a client-wiring defect that
HTTP-level integration tests structurally cannot see; a Playwright e2e
spec (e2e/search-scrubber.spec.ts) now drives the real bundle: sub-2-char
input stays closed, 2+ chars opens the swapped templ dropdown.
2026-10-27 19:04:59 -04:00
John O'Keefe e5d4744977 fix(audio): Shift+grab opens the precision rail anchored at the playhead
The first grab implementation degraded Shift+grab to a quarter-speed
relative crawl — losing the ±5-minute rail the user expects when they
grab the handle with Shift held. The rail is back: Shift+grab anchors
railWindow at the CURRENT position (not the nearest chapter line, and
never teleports the playhead on pointerdown); plain grab stays relative
1:1. GRAB_FINE_SCALE is deleted as dead code, and the dialog guide
documents the restored gesture.
2026-10-27 19:04:59 -04:00
John O'Keefe b462c7fb44 test(scanner): pin the demoted-genre contract the genre fix intended
TestAudiobookTagFallbacks still asserted the verbatim "Audiobook" genre
column, but 885d6140 demoted format words to tags-only — and that
commit's message explicitly says the pinned expectation was updated to
the demoted contract. The code change landed; the test edit it
describes never did, so the full suite has been red since 13:21 today
on a test my changes never touched.

Pin the new contract: the genre column stays empty for demoted format
words and the raw candidate ("Audiobook") lands in the tags array —
exactly the tags-only behavior the fix's message documents. Full
integration suite green after this.
2026-10-27 16:24:07 -04:00
John O'Keefe 59eddc1386 feat(metadata): audio tag rewrite via ffmpeg + CBZ ComicInfo injection
Phases 5b and 5c of the metadata writer.

5b — single-file audio (MP3/FLAC/M4A/M4B): the container's tags are
rewritten with ffmpeg -c copy (no re-encode; -map 0 keeps cover art,
-map_chapters 0 keeps chapters). The key set is exactly what the
scanner's applyAudioTagFallbacks reads back — title+album, subtitle,
artist/album_artist, composer (narrators, Audible's convention),
grouping + iTunes series-part, date, genre — so a write + rescan
round-trips. Output is ffprobe-VALIDATED (duration within 1s/0.5% of
the source) before the atomic rename; a lossy container rewrite never
replaces a good book. Fields the format can't carry per our own read
chain are deliberately not written, and fields_written reports only
what really round-trips (subtitle rides MP3/FLAC — this image's mp4
muxer drops subtitle keys, discovered empirically and pinned by test).

5c — CBZ: ComicInfo.xml injected (or replaced) with the same atomic zip
rewrite as the EPUB path, extended with an append-if-missing mode
since a comic may not carry one yet. Field map mirrors the scanner's
comic block: Title/Series/Number/Count/Volume, Writer (authors),
Summary, Publisher, Year/Month/Day, LanguageISO, Web, Genre+Tags;
fractional series numbers are omitted rather than truncated to a lie.
CBR/CB7 stay on the sidecar path (RAR/7z can't be rewritten in place).

Integration tests generate media at test runtime with ffmpeg (m4b) and
in-memory zips (cbz) — no binary fixtures — pinning the tag round-trip
through ffprobe, post-write rescan titles, the ComicInfo injection and
the validation-gate behavior.
2026-10-27 16:04:34 -04:00
John O'Keefe eaf1b2c8dd feat(metadata): write-to-file — embedded EPUB OPF + Calibre sidecars
Phase 5a of the metadata-writer feature: the book editor gains a
"Write to file" action (admin) that persists the item's CURRENT
database metadata into the artifact on disk, so the library stays
portable to Calibre and other apps — the thing Save alone can't do.

Dispatch (services/metadata_writer.go): EPUB/KEPUB gets its embedded
OPF spliced in place (fresh dc:* block generated inside the ORIGINAL
metadata open tag; manifest/spine/guide untouched; every other zip
entry copied byte-identical in original order so mimetype stays first
and STORED; original dc:identifiers preserved — the package's
unique-identifier must keep resolving). Everything else takes a
Calibre-style sidecar: metadata.opf when the book is ALONE in its
directory (Calibre's own convention — round-trips through Calibre
too), <basename>.opf when siblings share the folder, so one book's
sidecar can never poison dir-mates' scans (the scanner now reads both,
basename first).

Safety contract: temp file + atomic rename — the original is never
truncated on failure; the generated OPF is unit-tested to ROUND-TRIP
through the scanner's own parser (write side and read side can never
disagree); metadata_overrides are deliberately kept (file and DB agree
after the write; overrides still guard against stale sidecars). The
endpoint re-extracts inline (RescanMediaItem) and reports rescan
failure separately — a failed rescan doesn't undo the write. SHA-256
changes are inherent and documented for users (Kobo/KOReader re-key).

UI: the metadata editor modal carries the file path as a data
attribute; the button confirms before mutating, labels the target
(embedded vs sidecar) and toasts the outcome. Integration tests pin
the 403, missing-file error, EPUB embed + post-write rescan title, the
lone-book metadata.opf case, and the shared-dir <basename>.opf case
with no dir-scoped litter.
2026-10-27 15:36:43 -04:00
John O'Keefe 61e5f47ec0 feat(search): viewer progress + completed badges; dropdown to templ
Search results now show where you are in a book: a completed check at
the 0.999 threshold (same cut as the filter status arms), a partial
progress bar along the cover while mid-read, nothing when you never
opened it. Rendered in BookCard (any grid whose adapter loads viewer
progress) and in the header-search dropdown rows.

The progress rides a LEFT JOIN reading_progress on the SAME user_id
param SearchMediaItemsUnified already binds for library visibility —
one requester param, no new wire inputs, row-neutral join (the table
is UNIQUE per media_item+user). SearchMediaItemsUnifiedRow and BookInfo
gain the keys additively (viewer_percentage null when unknown,
library_name empty on feeds that don't load it); contract_pins.md
carries the changelog entry and the pin tests widen in lockstep.

The header dropdown itself migrates off the innerHTML string renderer
in search.ts (a rendering-ownership violation: a templ twin existed via
the HX-Request branch). The input now hx-gets layout=dropdown and swaps
the new templ SearchResults partial — titles/authors highlighted
server-side (escape-first, safe by construction), rows with the same
progress overlays — while a slim Alpine component owns only
client-local state (min-length gating, open/close, keyboard selection,
htmx lifecycle). web/src/rendering-allowlist.txt drops the search.ts
baseline to zero accordingly.

Integration test pins the per-user join (fresh user sees 1.0/0.42/null
on the fixture; the shared admin sees null everywhere — read-only) and
the dropdown partial's overlays/count/row count.
2026-10-27 15:11:49 -04:00
John O'Keefe d1f9ad3b25 feat(series): sort and filter the series page
The /series page rendered its grid in one fixed order (newest first)
with no way to find a series beyond eyeballing cards. The page now has
a toolbar — series-name search, author filter, viewer-progress filter
and a 12-key sort select — wired as a plain GET form (the page's
existing model: the URL is the single source of truth, and the library
switcher now carries q/author/status/sort along on switch).

Extensibility is deliberate (user request): sorts live in a registry
(services.ResolveSeriesSort) mirrored by a CASE cascade in
GetDistinctSeries and a <select> in series.templ — a new sort is one
entry in each. The sort value is a BOUND PARAM compared inside the
cascade, never interpolated; unknown keys resolve to the default.
Filters: series-name matches in WHERE (the group key, so counts stay
true); author (BOOL_OR any-book match) and status (finished/reading/
unstarted vs the viewer's progress, same 0.999 threshold as the
filter_query status arms) in HAVING so COUNT(*) keeps reporting the
whole series. GetDistinctSeriesCount shares the JOIN + HAVING so
pagination totals match. The reading_progress join can't multiply rows
(the table is UNIQUE per media_item+user).

Cards gain the viewer's finished-count ("X of N read" + progress bar),
and the empty state distinguishes "no series at all" from "no matches —
clear filters". The JSON /api/series endpoint takes the same params and
gains read_count/last_read_at additively.

Integration test pins every sort key against a three-series fixture
with distinct created/author/count/progress answers, the filter matrix,
composition, unknown-sort fallback, card read-counts and the JSON
contract — all as the fresh regular user so the live reader's data is
untouched.
2026-10-27 14:53:45 -04:00
John O'Keefe 79f4d668f1 fix(audio): grabbing the playhead thumb no longer teleports it
A pointerdown on the scrub bar unconditionally mapped the click X to a
position, so grabbing the handle itself made the playhead jump — worst
with Shift, whose branch force-snapped to the nearest chapter line
before the drag even started. The user's complaint: the handle click
flowed through to the track-click behavior behind it.

The bar now hit-tests the thumb first (GRAB_SLOP_PX, a pixel test like
the chapter magnetism — a seconds threshold goes sub-pixel on long
books). A grab enters a new "grab" drag mode: the position stays where
it is until the pointer moves, then advances RELATIVELY via the new
applyGrab helper (incremental like the touch cursor, fixed scale — the
mouse is always on the bar, so Shift = quarter-speed fine crawl instead
of a pull-away zoom).

Track clicks keep today's behavior exactly: click = jump with chapter
magnetism, Shift+click = nearest chapter line, Shift+drag = precision
rail, touch = incremental pull-away. MOUSE_GUIDE gains the two grab
rows so the dialog documents the gesture; new pure helpers are unit-
tested alongside the existing scrub math.
2026-10-27 14:21:13 -04:00
John O'Keefe c71ef4af85 fix(auth): show error feedback on failed htmx login/registration
Found while double-checking /login after the redirect fix: a wrong
password left the form completely silent. Two stacked causes, both
pre-dating the CSP work:

1. The two HX-Request branches for invalid credentials returned JSON,
   unlike every sibling error branch — there was no HTML partial to
   show at all. They now return a shared invalidCredentialsHTML div
   (the JSON shape is kept for non-htmx API callers).
2. htmx does not swap 4xx/5xx responses by default, so even the HTML
   error partials that DID exist (429 lockout, 500s, registration
   failures like "Email already exists") never landed in the target.
   auth_forms.ts now swaps them itself on htmx:beforeSwap when the
   error response is text/html — JSON errors stay ignored.

Verified live: /login success → /dashboard; wrong password shows
"Invalid email/username or password." and stays on /login; duplicate
email on /register shows "Email already exists". Gates: make test 9
pkgs, vitest 155, Playwright 20.
2026-10-27 14:05:36 -04:00
John O'Keefe 7c9e561e68 fix(auth): login/registration forms redirect again under hash CSP
The htmx login/registration partials answered success with an inline
<script> that stashed the auth token and set window.location — refused
by hash-mode script-src (no 'unsafe-inline'), so the user saw "Login
successful! Redirecting..." and nothing else (reported from the home-
page login form; /login was equally broken). The same pattern lived in
collections.go (delete → redirect) and devices.go (token regen →
reload, whose script tag was also unclosed).

Replacement, per the AGENTS.md CSP rules:
- auth.go login/registration: the partial now carries data-auth-token +
  data-redirect (redirect is path-only — open-redirect hygiene — and
  escaped); web/src/auth_forms.ts (new, armed from main.ts on every
  page) listens for htmx:afterSwap, stashes the token, and navigates.
- collections.go delete: data-redirect, same listener.
- devices.go token regen: htmx's native HX-Refresh response header.
- The dead localStorage 'user'/'refreshToken' writes are dropped — no
  reader anywhere in web/src; only 'token' is load-bearing (19 modules).

Tripwire extension: internal/middleware/csp_go_sources_test.go scans
non-test Go sources for literal <script> tags and native on*= attributes
(hand-written files only — templ codegen is owned by the .templ scan),
verified to fire on a reintroduced probe. AGENTS.md CSP section notes
the Go-emitted-partial vector.

Verified live: home-page login form → /dashboard with token stashed and
dashboard loading clean; /register form → /dashboard; collections and
devices share the same mechanisms (data-redirect / HX-Refresh). Gates:
make test (9 pkgs incl. both tripwires), vitest 155, Playwright 20,
tsc/vp clean.
2026-10-27 13:57:41 -04:00
John O'Keefe b7595c3011 docs(agents): commit each completed unit of work as it lands
Release / build-and-push (push) Successful in 3m20s
Standing rule: commit per related edit with detailed conventional-commit
messages as each project section finishes, instead of batching a whole
session into one giant commit. Committing is local bookkeeping; pushing
stays a separate, explicit decision. Ordering guidance added so stacked
trees compile (service before handler, guard before dependent feature).
2026-10-27 13:39:02 -04:00
John O'Keefe 6156ff6154 fix(docs): record keep_screen_awake in the get_profile example
TestDocsMatchRoutes flagged the example response as missing a key the
handler emits — drift introduced by c645b8e2 (the wake-lock preference),
surfaced while regenerating the API mirrors for the retry endpoint.
2026-10-27 13:23:09 -04:00
John O'Keefe 69d53e92a7 test(integration): cover retry, folder-path validation, and the archive guard
- TestRetryProcessingIssueRowless: a row-less import_failed entry retries
  to a real import and resolves; a missing file retries to 409 with the
  real reason and stays open; an item-backed issue retries through the
  rescan mechanism and resolves.
- TestScanLibraryFolderPathValidation: an unregistered folder_path is a
  400; a registered one scopes and enqueues (202).
- TestScanSingleFolderSkipsArchiveReconciliation: scanning one folder of
  a two-folder library leaves the other folder's healthy item at
  missing_scan_count 0 / unarchived — pinning the partial-scope guard
  against the false-archive regression it exists to prevent.
2026-10-27 13:23:02 -04:00
John O'Keefe 248bb7816d feat(issues): Processing Issues page retry UI with a sequential client queue
Per issue card: a Retry button — error-severity issues previously had no
action at all (only warning/info got Dismiss). Right of the Errors stat
card: Retry all, which builds a queue of every issue currently on the
page and runs them strictly one at a time through the per-issue retry
endpoint. The browser is the queue — resource use is bounded to a single
book in flight, and no batch endpoint exists to maintain.

While the queue runs, the button shows an n/m counter and cards tint via
client-local state classes (the sanctioned island shape; the cards
themselves stay templ-rendered). After the queue drains, the page swaps
in the new /admin/libraries/:id/issues/content templ partial — the
unlinked-books htmx.ajax pattern with a reload fallback — so resolved
issues drop off server-side while failures remain listed, with a
summary toast and per-item error toasts carrying the real failure
reasons.

CSP discipline holds throughout: Alpine @click component methods, no
inline handlers, no js:-prefixed hx-vals.
2026-10-27 13:22:54 -04:00
John O'Keefe 0f1d0abbb0 feat(libraries): scope a library scan to one registered folder
POST /api/libraries/:id/scan accepts an optional {"folder_path": "..."}:
the scan then covers exactly that one registered folder's subtree instead
of the whole library. The path is validated against the library's
registered folders — anything else is a 400, never trusted. An omitted
body keeps today's full-library behavior, and coalescing (409 with the
active job) is unchanged.

The LibraryPanel folder rows get a per-folder Scan button: pure htmx
attributes posting to the same scan endpoint, with hxValsFolderScan
emitting plain eval-free JSON for hx-vals alongside the existing
hxValsFolderDelete precedent. Progress surfaces through the existing
active-scan polling; no new client code.

Safety: the scanner's archive-reconciliation guard (previous commit)
skips partially-covered libraries, so a one-folder scan cannot mark the
library's other folders' items missing.
2026-10-27 13:22:41 -04:00
John O'Keefe 6f37be59fc feat(issues): retry a processing issue through the individual scanner
POST /api/libraries/:id/issues/:issueId/retry (admin, family-lens gated
via the existing gateIssue) re-runs the work that produced an open issue
— deliberately NOT a library-wide rescan. Two shapes mirror the two
ledger entry kinds:

- item-backed issues rescan their media item through the book-detail
  RescanMediaItem mechanism (self-resolving folders, multi-file
  audiobook routing, write-after-success guards) and then close the
  issue (RetryMediaItemIssue);
- row-less import_failed issues re-import the single file through the
  watcher's standalone processMediaFile path (RetryFailedImport: folders
  resolved RescanMediaItem-style so paths stay library-root-relative),
  whose create-path self-heal plus an explicit path resolve closes the
  issue even when the content was already imported by a prior attempt.

Failures return the REAL underlying error so the UI can show why the
issue still exists; a vanished file is a 409 (ErrRetryFileMissing);
already-resolved issues reject re-retry.

Docs + bruno mirrors regenerated for the new route (the generator's seq
renumbering touches three unrelated bruno files as fallout; openapi
gains the path and the date bump), and the docs schema registry gains
the retry entry so TestDocsMatchRoutes passes.
2026-10-27 13:22:31 -04:00
John O'Keefe fb117635f8 feat(scanner): ledger failed metadata updates; guard partial-scope archive passes
Two scanner-integrity changes supporting the Processing Issues retry work:

- The size-change and sidecar-change update paths only printf'd their
  failures, so a book whose metadata update kept failing (the Crusader
  genre-22001 loop) degraded silently on every scan with nothing on the
  Processing Issues page. Both paths now record an item-backed
  metadata_extraction_failed issue — the same type and shape the
  sidecar-extraction guard already used — and resolveIssuesForItem
  clears it when an update finally succeeds.

- The archive reconciliation pass compares every library item against
  what one scan saw on disk. The new per-folder scan scope runs a scan
  covering a single folder, which would mark every item under the
  library's OTHER folders missing and archive them on a later pass.
  The pass now skips a library whose registered folders the scan only
  partially covered (GetLibraryFolders vs the scan's folder set),
  logging the skip.
2026-10-27 13:21:48 -04:00
John O'Keefe 885d614092 fix(scanner): route the audio-tag genre through the smart-genre pipeline
applyAudioTagFallbacks captured the ffprobe genre atom verbatim into
media_items.genre — no splitting, no shelf resolution, no cap. That raw
capture is what fed 100+-char Audible category blobs into the column
(the 2026-10 Crusader import/update failures).

The ABS sidecar path already has the correct doctrine (ab2a5568): a known
lexicon shelf wins the genre column, everything else is tags-only. Give
the tag fallback the same treatment — splitGenreCandidates +
pickSmartGenre for the column, split candidates appended to tags
(NormalizeTags dedups). Demoted genres like "Audiobook" stop masquerading
as a shelf, and an unknown blob can no longer manufacture an overflowing
value at all.

Fixtures pin the new behavior: the real 111-char Crusader blob stays out
of the column entirely and lands as six separate tags; a shelf-carrying
blob still wins the column; the previously-pinned verbatim "Audiobook"
expectation is updated to the demoted (tags-only) contract.
2026-10-27 13:21:39 -04:00
John O'Keefe d42c78f58c fix(schema): widen media_items.genre to TEXT
The genre column was VARCHAR(100), and the audio tag fallback stores the
file's raw genre atom verbatim. Libation writes Audible's category ladder
as one comma-joined string ("Politicians, Presidents & Heads of State,
United States, Russia, Communism & Socialism, International Relations" —
111 chars), so any such book failed its import or metadata update with
SQLSTATE 22001 (value too long), taking the whole row down with it.

VARCHAR -> TEXT is a metadata-only change in Postgres and safe to re-run;
the in-place ALTER follows the media_highlights position-widening
precedent so existing databases converge on restart (schema.sql runs on
every boot).
2026-10-27 13:21:29 -04:00
John O'Keefe 8c97f59453 feat(dashboard): move This Week stats into the library bar as a popover
Release / build-and-push (push) Successful in 3m11s
The "This Week" reading-stats strip leaves its full-width row below the
subbar and now lives inside the library bar itself, between the switcher
and the customize/refresh actions. Expanding it opens an absolutely
positioned popover that FLOATS over the collections — opening the stats
no longer shifts the page down.

- LibrarySwitcher gains an optional `center` component slot rendered
  between the left group and the actions group (nil = unused). Series
  and collections call sites pass nil and render byte-identical output.
- DashboardWeekWidget redesigned for the slot: compact inline summary
  (icon, "This Week", minutes; pages/sessions/books/streak reveal at
  md/lg/xl) and a card popover panel with the labeled grid, sync note,
  and the Full stats link moved in from the old summary row.
- Panel anchoring is responsive: below `sm` it anchors to the sticky
  bar itself (details goes static; bar-anchored left-4/right-4, i.e.
  screen-centered); at `sm`+ the details is relative again and the
  panel centers on the widget (w-[30rem], translate-x centering).
- On phones the summary collapses to the chart icon + chevron (bar row
  width is scarce); "This Week"/minutes reveal at `sm`, and the
  "Your Library" title hides below `sm` to keep the row from
  overlapping. Icon-only toggle carries an aria-label.
- web/src/dashboard.ts: click-outside and Escape close the popover,
  re-querying #week-widget per event because htmx replaces it on
  library switches. Rendering untouched (behavior-only island).
- id="week-widget" and all htmx wiring unchanged: week-refresh refetch
  + the /dashboard/week-widget fragment still drive library-scoped
  refreshes behind the switch fade; stats-off suppression now yields an
  empty slot via weekWidgetComponent instead of a hidden strip.
- web/static/style.css rebuilt for the new utilities.

Known behavior (unchanged or deliberate): the bar is sticky, so stats
stay visible while scrolling; a library switch re-renders the details
closed; scopes with no current-week reading show zeros (e.g. Manga).
2026-10-27 10:22:13 -04:00
John O'Keefe f6a73ab5aa feat(unlinked-books): dismiss (ignore) or delete queue entries
Release / build-and-push (push) Successful in 3m15s
Unidentified sync files used to be stuck: every KOReader re-sync
re-queues them, and there was no way to say "this isn't a book" or "I
don't want this one on the server".

Ignore is the durable dismiss: a new ignored_at column hides the row
from the page list, dashboard badge, and per-device API while the sync
keeps refreshing its metadata (the upsert never clears the column).
A collapsible "Ignored" section lists dismissed rows with Restore and
Delete so a misclick is recoverable. Delete is the hard exit — the row
is gone, and honestly re-queued if the device syncs that file again
(the confirm dialog says so).

Actions are page-scoped SSR routes: htmx swaps in the re-rendered
section so counts and buckets can never drift from the DB; ownership
is row → device → user (admins may act on any row). Integration tests
pin the lifecycle (hide everywhere, survive the sync upsert, restore,
delete), foreign-user 403s, and the render surface.
2026-10-26 14:58:50 -04:00
John O'Keefe afbf1029a9 feat(web): hold a Screen Wake Lock while reading or listening
Release / build-and-push (push) Successful in 3m12s
New wake-lock.ts engine (sanctioned island, dependency-injected for
tests): feature-detected navigator.wakeLock, silent no-op when
unsupported or denied, and the spec-mandated force-release-on-hide →
re-acquire-on-visible loop handled via the sentinel release event.
The player acquires on play and releases on pause (sleep-timer fades
ride the same path); the reader holds the lock for the session. Both
gate on the keep_screen_awake preference — data-keep-awake on the
player root, keepScreenAwake in readerInitExpr.

Hidden-tab playback intentionally does not keep the screen awake:
the spec forbids screen wake locks for hidden documents.
2026-10-26 13:59:37 -04:00
John O'Keefe c645b8e207 feat(profile): keep_screen_awake user preference (default on)
New users column surfaced as an On/Off dropdown in the profile form.
The form and API carry "true"/"false" strings with the theme-style
""-guard, so JSON clients that omit the field can't clobber it;
anything else is rejected. GET/PUT /api/auth/profile expose
keep_screen_awake alongside the other profile fields, and the reader
route threads the preference into its template metadata.

Integration tests pin the schema default, both values, omission
semantics, and rejection — dedicated test user, never testuser.
2026-10-26 13:59:32 -04:00
John O'Keefe bdb9132b85 chore(auth): drop dead UpdateProfileRequest; rename shared struct
The combined profile handler (self-edit PUT /api/auth/profile and
admin PUT /api/auth/profile/:id) binds one struct for both paths —
and it's the admin-named one. Its twin UpdateProfileRequest had zero
references left. Delete it and rename AdminUpdateUserRequest to
ProfileUpdateRequest so the name matches who actually binds it; the
comments describing field semantics now stand on their own instead of
pointing at the deleted twin. Wire format (json/form tags) unchanged;
docs schema registration follows the rename.
2026-10-26 13:59:22 -04:00
136 changed files with 10710 additions and 2919 deletions

No files matched your search

+31 -1
View File
@@ -4,6 +4,32 @@ Standing rules for any agent or developer working in this repo.
Project-wide context lives in the sibling repos' AGENTS.md files
(../bookhoard-app/AGENTS.md is the Android client's).
## Commit as each unit of work completes — ALWAYS apply
Commit when a logically complete piece of the project is done — a fix,
a feature slice, a schema change, a docs/test batch — before moving on
to the next piece. Do not hold a whole session's work for one giant
end-of-session commit, and never push uncommitted work aside until the
tree is clean.
- **One commit per related edit.** If the changed files serve more than
one purpose, they are separate commits; only share a commit when you
are sure every file in it is the same related edit. Generated
artifacts (templ codegen, sqlc output, vite/tailwind build output,
bruno/openapi mirrors) commit with the source change that produced
them.
- **Detailed messages**, in the repo's conventional-commit style
(`fix(scanner): …`, `feat(issues): …`) with a body that says WHY the
change exists, what behavior changed, and any precedent it follows —
not just what the diff shows. The body is how the next agent (and
git history) arbitrates "test stale vs code changed".
- **Ordering**: stack commits so each tree compiles where practical
(a service before the handler that calls it, a guard before the
feature that relies on it).
- **Committing ≠ pushing.** Commits are local bookkeeping and land as
work completes; pushing (once, after the last commit) is the author's
call. Never push half-finished units just because they were committed.
## Rendering ownership (templ vs TypeScript) — ALWAYS apply
The point of templ + htmx + Alpine is: the server renders the UI, and
@@ -54,7 +80,11 @@ refuses, **silently at build/test time**, two authoring patterns:
guard means recomputing the hash (the failing test prints it); adding
a *second* inline script means hashing it too and extending
`cspSanctionedInlineScripts` in `templates/csp_source_test.go`.
Everything else goes in a bundled `web/src` module.
Everything else goes in a bundled `web/src` module. This includes
partials emitted from Go handler strings — those use data attributes
+ `web/src/auth_forms.ts` (post-swap redirect/stash) or htmx's
`HX-Redirect`/`HX-Refresh` headers; `internal/middleware`'s
Go-source scan is the tripwire for that vector.
- **No native `on*=` handler attributes** (`onclick=`, `onerror=`,
`onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline
handlers never go through the hash path; hash-mode CSP refuses them
@@ -0,0 +1,23 @@
info:
name: Retry Processing Issue
type: http
seq: 18
http:
method: POST
url: '{{base_url}}/api/libraries/{{ebook_library_id}}/issues/{{issueid}}/retry'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Retry Processing Issue
`POST /api/libraries/:id/issues/:issueId/retry` — auth: jwt+admin.
Canonical reference: docs/developer/api/libraries/retry_processing_issue.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Scan Library
type: http
seq: 18
seq: 19
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Set Library Visibility
type: http
seq: 19
seq: 20
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Update Library
type: http
seq: 20
seq: 21
http:
method: PUT
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Update Scan Settings
type: http
seq: 21
seq: 22
http:
method: PUT
@@ -0,0 +1,87 @@
package main
// Regression tests for the archive reconciliation pass: a multi-folder
// library must be reconciled against the UNION of its folders' walks, not
// once per folder. The old per-folder loop listed every library item
// against each single folder's walk, so with two registered folders every
// item living under the other folder was flagged "missing (1/2)" on every
// scan (prod: 743/743 audiobooks stuck at count 1 — unarchivable only
// because each scan's walk kept clearing them), and with three or more
// folders the crossed marks would have reached the archive threshold
// mid-pass and archived real content. Folder-book roots (directory
// file_path) must also count as present across folder boundaries — the
// file walk never emits a directory.
import (
"context"
"fmt"
"path/filepath"
"testing"
"time"
"bookhoard/internal/database"
"bookhoard/internal/services"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// scanTestFolders runs a synchronous scan over several folders — the
// multi-folder shape the archive pass must get right (scanTestFolder
// covers the single-folder case).
func scanTestFolders(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folders []string) {
t.Helper()
scanner := services.NewMediaScanner(setup.DB)
defer func() { _ = scanner.Close() }()
scanner.SetAdminID(adminID)
require.NoError(t, scanner.SetFolders(folders, false))
require.NoError(t, scanner.ScanFolders(context.Background()))
}
func TestArchivePassMultiFolderLibraryKeepsEverythingPresent(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
libID, folderA := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Archive MultiFolder %d", time.Now().UnixNano()), "audiobooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) }) // after setup.Close defer (LIFO): pool still open
// Second registered folder in the SAME library — the shape the
// per-folder reconciliation got wrong.
folderB := t.TempDir()
_, err := setup.DB.AddLibraryFolder(context.Background(), database.AddLibraryFolderParams{
LibraryID: libID,
FolderPath: folderB,
})
require.NoError(t, err)
admin, _ := moveTestAdminID(t, setup)
// Folder A holds a lone single-file book; folder B holds a
// folder-of-tracks book whose file_path is a DIRECTORY.
writeTestTrack(t, filepath.Join(folderA, "Author A", "Lone Book", "lonely.mp3"))
writeTestTrack(t, filepath.Join(folderB, "Author B", "Folder Book", "01 First.mp3"))
writeTestTrack(t, filepath.Join(folderB, "Author B", "Folder Book", "02 Second.mp3"))
// Two consecutive scans: the first proves no flags are raised, the
// second proves the flag/walk bounce (clear → re-flag) is gone too.
for scanNo := 1; scanNo <= 2; scanNo++ {
scanTestFolders(t, setup, admin, []string{folderA, folderB})
items := listLibraryAudioItems(t, setup, libID)
var singleFile, folderBook bool
for _, item := range items {
assert.False(t, item.ArchivedAt.Valid, "scan %d: item %q must not be archived", scanNo, item.FilePath)
assert.Equal(t, int32(0), item.MissingScanCount, "scan %d: item %q must not be flagged missing", scanNo, item.FilePath)
switch item.FilePath {
case "Author A/Lone Book/lonely.mp3":
singleFile = true
case "Author B/Folder Book":
folderBook = true
}
}
require.True(t, singleFile, "scan %d: single-file book must exist", scanNo)
require.True(t, folderBook, "scan %d: folder book must exist at its directory path", scanNo)
require.Len(t, items, 2, "scan %d: exactly the two books, no per-track strays", scanNo)
}
}
+7 -1
View File
@@ -25,6 +25,7 @@ import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -411,7 +412,12 @@ func TestAudiobookTagFallbacks(t *testing.T) {
require.Len(t, item.Narrators, 1, "composer tag fills narrators (Audible convention)")
require.Equal(t, "Tag Narrator", item.Narrators[0])
require.Equal(t, "Tag Series", item.Series.String, "grouping tag fills series")
require.Equal(t, "Audiobook", item.Genre.String, "genre tag fills genre")
// 885d6140 demoted format words ("Audiobook") to tags-only: the
// genre column stays empty and the raw candidate lands in tags.
// (The commit message declares this test's update; the edit itself
// never landed — pinned to the demoted contract now.)
require.Empty(t, item.Genre.String, "demoted format words never win the genre column")
assert.Contains(t, item.Tags, "Audiobook", "the demoted candidate lands as a tag")
require.False(t, item.DatePublished.Time.IsZero(), "date tag fills publish date")
require.Equal(t, 2020, item.DatePublished.Time.Year())
// ASIN/abridged/subtitle arrive only through Audible freeform atoms,
+10
View File
@@ -452,9 +452,13 @@ func TestContractPin_KoboSyncFromServer(t *testing.T) {
// ---------------------------------------------------------------------------
// bookInfoKeys is the BookInfo JSON every card surface emits.
// 2026-10-27 (search-progress batch): + viewer_percentage (nil = no
// viewer data; only the search-unified adapter populates it today)
// and + library_name (same deal). docs/contract_pins.md changelog.
var bookInfoKeys = []string{
"media_item_id", "title", "author", "cover_image_path",
"has_conflict", "format_group", "badge",
"viewer_percentage", "library_name",
}
func collectionURL(setup *TestServerSetup, id string) string {
@@ -667,6 +671,12 @@ func TestContractPin_SearchJSON(t *testing.T) {
// (docs/contract_pins.md changelog).
"subtitle", "explicit", "authors", "authors_search",
"publishers", "publishers_search", "narrators_search",
// 2026-10-27 (search-progress batch): + viewer_percentage —
// the requester's reading_progress.percentage (LEFT JOIN on
// the same user_id param the visibility join binds), null
// when they never opened the book (docs/contract_pins.md
// changelog).
"viewer_percentage",
}
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
+444
View File
@@ -0,0 +1,444 @@
package main
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMetadataWriteToFile pins the write-to-file endpoint (2026-10-27):
// POST /api/media-items/:id/write-metadata (admin only) persists the
// item's CURRENT database metadata into the artifact — embedded OPF for
// EPUBs, a Calibre-style sidecar for formats that can't carry metadata
// — then re-extracts. Sidecar naming: metadata.opf when the item is the
// only book in its directory, <basename>.opf otherwise (one book's
// sidecar must never poison dir-mates).
//
// Fixture files live under /app/uploads (the container's library
// folder) and are removed on cleanup; the test library delete
// cascades the items.
func TestMetadataWriteToFile(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
ctx := context.Background()
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Metadata Write Library", true)
uploadsDir := "/app/uploads"
insertItem := func(relPath, title string) string {
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, $2, $3, 'ebook', 'application/epub+zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
libID, title, relPath,
).Scan(&itemID)
require.NoError(t, err)
return itemID
}
writeEPUB := func(absPath, title string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(absPath), 0o755))
var buf bytes.Buffer
w := zip.NewWriter(&buf)
mimetype, _ := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
_, _ = mimetype.Write([]byte("application/epub+zip"))
container, _ := w.Create("META-INF/container.xml")
_, _ = container.Write([]byte(`<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`))
opf, _ := w.Create("OEBPS/content.opf")
_, _ = opf.Write([]byte(fmt.Sprintf(`<?xml version="1.0"?><package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title id="t1">%s</dc:title><dc:creator>Original Author</dc:creator><dc:identifier id="pub-id">urn:uuid:abcd</dc:identifier></metadata><manifest><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="c1"/></spine></package>`, title)))
ch, _ := w.Create("OEBPS/c1.xhtml")
_, _ = ch.Write([]byte("<html><body>x</body></html>"))
require.NoError(t, w.Close())
require.NoError(t, os.WriteFile(absPath, buf.Bytes(), 0o644))
}
callWrite := func(token, itemID string) (int, map[string]interface{}) {
req, _ := http.NewRequest("POST", fmt.Sprintf("%s/api/media-items/%s/write-metadata", setup.Server.URL, itemID), nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
var body map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
return resp.StatusCode, body
}
// Edit the item's metadata the way the editor does (PUT), then ask
// the endpoint to persist it.
editItem := func(itemID string, fields map[string]interface{}) {
body, _ := json.Marshal(fields)
req, _ := http.NewRequest("PUT", fmt.Sprintf("%s/api/media-items/%s", setup.Server.URL, itemID), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "PUT should succeed")
}
t.Run("non-admin is forbidden", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/forbidden/book.epub", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
writeEPUB(abs, "Forbidden Book")
itemID := insertItem(rel, "Forbidden Book")
code, _ := callWrite(setup.RegularToken, itemID)
assert.Equal(t, http.StatusForbidden, code)
})
t.Run("missing file errors without sidecar litter", func(t *testing.T) {
itemID := insertItem(fmt.Sprintf("test-metadata-write-%d/ghost/book.epub", os.Getpid()), "Ghost Book")
code, body := callWrite(setup.Token, itemID)
assert.Equal(t, http.StatusInternalServerError, code)
assert.Contains(t, body["error"], "missing on disk")
})
t.Run("EPUB metadata is embedded and rescan keeps it", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/solo/book.epub", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
writeEPUB(abs, "Original EPUB Title")
itemID := insertItem(rel, "Original EPUB Title")
editItem(itemID, map[string]interface{}{
"title": "Embedded New Title",
"authors": []string{"Embedded Author"},
"series": "Embedded Series",
"series_number": 2,
})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "embedded-opf", body["metadata_target"])
// The zip on disk now carries the new values — verified by
// PARSING the rewritten OPF (not string matching), with the
// ORIGINAL unique-identifier surviving the splice.
reader, err := zip.OpenReader(abs)
require.NoError(t, err)
var opfContent []byte
for _, f := range reader.File {
if f.Name == "OEBPS/content.opf" {
rc, err := f.Open()
require.NoError(t, err)
opfContent, err = io.ReadAll(rc)
require.NoError(t, rc.Close())
require.NoError(t, err)
}
}
reader.Close()
var opf struct {
Metadata struct {
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
Metas []struct {
Name string `xml:"name,attr"`
Content string `xml:"content,attr"`
} `xml:"meta"`
} `xml:"metadata"`
}
require.NoError(t, xml.Unmarshal(opfContent, &opf))
require.NotEmpty(t, opf.Metadata.Titles, "dc:title present in written OPF")
assert.Equal(t, "Embedded New Title", opf.Metadata.Titles[0])
assert.NotContains(t, opf.Metadata.Titles, "Original EPUB Title", "old title replaced")
require.NotEmpty(t, opf.Metadata.Creators)
var seriesMeta string
for _, m := range opf.Metadata.Metas {
if m.Name == "calibre:series" {
seriesMeta = m.Content
}
}
assert.Equal(t, "Embedded Series", seriesMeta)
assert.Contains(t, string(opfContent), "urn:uuid:abcd", "original identifier preserved for unique-identifier")
// The inline rescan re-extracted from the file: the DB title
// still reads the written value.
var title string
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
assert.Equal(t, "Embedded New Title", title)
})
t.Run("single-book dir gets a Calibre-style metadata.opf sidecar", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/alone/book.pdf", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
require.NoError(t, os.WriteFile(abs, []byte("%PDF-1.4 fake"), 0o644))
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Sidecar Book', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
libID, rel,
).Scan(&itemID)
require.NoError(t, err)
editItem(itemID, map[string]interface{}{"title": "Sidecar New Title"})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "sidecar-opf", body["metadata_target"])
sidecar := filepath.Join(filepath.Dir(abs), "metadata.opf")
data, err := os.ReadFile(sidecar)
require.NoError(t, err, "metadata.opf must exist next to a lone book")
// Parse the sidecar like the scanner does: title must be present
// as a dc:title ELEMENT, not just a substring.
var opf struct {
Metadata struct {
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
} `xml:"metadata"`
}
require.NoError(t, xml.Unmarshal(data, &opf))
require.NotEmpty(t, opf.Metadata.Titles)
assert.Equal(t, "Sidecar New Title", opf.Metadata.Titles[0])
})
t.Run("shared dir falls back to <basename>.opf", func(t *testing.T) {
// PDFs can't carry metadata, so they take the sidecar path —
// and with two books in one directory, the sidecar must be
// per-file or A's write would poison B's next scan.
dir := fmt.Sprintf("test-metadata-write-%d/shared", os.Getpid())
relA, relB := dir+"/a.pdf", dir+"/b.pdf"
absA, absB := filepath.Join(uploadsDir, relA), filepath.Join(uploadsDir, relB)
require.NoError(t, os.MkdirAll(filepath.Dir(absA), 0o755))
require.NoError(t, os.WriteFile(absA, []byte("%PDF-1.4 fake a"), 0o644))
require.NoError(t, os.WriteFile(absB, []byte("%PDF-1.4 fake b"), 0o644))
var itemA string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Book A', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
libID, relA,
).Scan(&itemA)
require.NoError(t, err)
var itemB string
err = setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Book B', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
libID, relB,
).Scan(&itemB)
require.NoError(t, err)
editItem(itemA, map[string]interface{}{"title": "Book A Rewritten"})
code, body := callWrite(setup.Token, itemA)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "sidecar-opf", body["metadata_target"])
// a.opf carries A's metadata; NO dir-scoped metadata.opf
// may appear (it would poison B's next scan), and B gets no
// sidecar from A's write. The name strips the extension,
// matching the scanner's {basename}.jpg cover convention.
data, err := os.ReadFile(filepath.Join(uploadsDir, dir, "a.opf"))
require.NoError(t, err, "shared dir must use <basename>.opf")
assert.Contains(t, string(data), "Book A Rewritten")
_, err = os.Stat(filepath.Join(uploadsDir, dir, "metadata.opf"))
assert.True(t, os.IsNotExist(err), "no dir-scoped sidecar over a multi-book folder")
_, err = os.Stat(filepath.Join(uploadsDir, dir, "b.opf"))
assert.True(t, os.IsNotExist(err), "B gets no sidecar from A's write")
_ = itemB
})
t.Run("audio tags rewrite round-trips for every container", func(t *testing.T) {
// Each single-file container gets its own generated fixture
// (ffmpeg at test runtime — no binary media in git), its own
// write, and an INDEPENDENT verification pass: ffprobe the
// rewritten file and assert every mapped key landed. This is the
// "check the file's metadata yourself" contract per format:
//
// album/artist/composer/grouping/date/genre → all four
// subtitle → mp3 + flac only
// (the mp4 muxer
// drops it)
cases := []struct {
ext string
audioCodec string
wantSubtitle bool
}{
{ext: ".mp3", audioCodec: "libmp3lame", wantSubtitle: true},
{ext: ".flac", audioCodec: "flac", wantSubtitle: true},
{ext: ".m4a", audioCodec: "aac", wantSubtitle: false},
{ext: ".m4b", audioCodec: "aac", wantSubtitle: false},
}
for _, tc := range cases {
t.Run(tc.ext, func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/audio-%s/book%s", os.Getpid(), strings.TrimPrefix(tc.ext, "."), tc.ext)
abs := filepath.Join(uploadsDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
gen := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-t", "2", "-c:a", tc.audioCodec,
"-metadata", "title=Generated Fixture",
"-metadata", "album=Generated Fixture",
abs)
if out, err := gen.CombinedOutput(); err != nil {
t.Skipf("fixture generation failed for %s (%v): %s", tc.ext, err, string(out))
}
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Generated Fixture', $2, 'audiobook', $3, 100, NOW(), NOW(), NOW()) RETURNING id`,
libID, rel, "audio/"+strings.TrimPrefix(tc.ext, "."),
).Scan(&itemID)
require.NoError(t, err)
// Follow the real editor flow: PUT first (records
// metadata_overrides, so the inline rescan keeps the
// edited values instead of folder-structure derivations).
editItem(itemID, map[string]interface{}{
"title": "Audio New Title",
"subtitle": "Audio Sub",
"authors": []string{"Audio Author"},
"narrators": []string{"Narrator Person"},
"series": "Audio Series",
"series_number": 1.5,
"date_published": "1953-04-16",
"tags": []string{"Science"},
})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code, "%s write", tc.ext)
assert.Equal(t, "audio-tags", body["metadata_target"], tc.ext)
// ---- verify the FILE's metadata independently ----
probe := func() map[string]string {
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
"format_tags", "-of", "json", abs).Output()
require.NoError(t, err)
var parsed struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
}
require.NoError(t, json.Unmarshal(out, &parsed))
return parsed.Format.Tags
}
tags := probe()
assert.Equal(t, "Audio New Title", tags["album"], "%s: the scanner's album rung", tc.ext)
assert.Equal(t, "Audio New Title", tags["title"], tc.ext)
assert.Equal(t, "Audio Author", tags["artist"], tc.ext)
assert.Equal(t, "Narrator Person", tags["composer"], "%s: narrators ride composer", tc.ext)
assert.Equal(t, "Audio Series", tags["grouping"], tc.ext)
assert.Equal(t, "Science", tags["genre"], tc.ext)
assert.Equal(t, "1953-04-16", tags["date"], tc.ext)
assert.NotContains(t, tags["album"], "Generated Fixture", "%s: old tag replaced", tc.ext)
if tc.wantSubtitle {
assert.Equal(t, "Audio Sub", tags["subtitle"], "%s: subtitle round-trips", tc.ext)
} else {
assert.Empty(t, tags["subtitle"], "%s: subtitle must not be written where it can't round-trip", tc.ext)
writeMap := body["write"].(map[string]interface{})
assert.NotContains(t, writeMap["fields_written"], "subtitle", "%s: honest fields_written", tc.ext)
}
// Still valid, same-length audio after the rewrite.
durOut, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
"format=duration", "-of", "default=noprint_wrappers=1:nokey=1", abs).Output()
require.NoError(t, err)
dur, err := strconv.ParseFloat(strings.TrimSpace(string(durOut)), 64)
require.NoError(t, err)
assert.Greater(t, dur, 1.5, "%s: audio survived the rewrite", tc.ext)
// The inline rescan re-read the tags: the DB title stays
// the written one.
var title string
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
assert.Equal(t, "Audio New Title", title, tc.ext)
})
}
})
t.Run("CBZ gets ComicInfo.xml injected and rescan keeps it", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/comic/book.cbz", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
// Minimal archive: one "page" file (a text stub — cover
// extraction failing is tolerated by the scanner) and no
// ComicInfo.xml yet.
var buf bytes.Buffer
w := zip.NewWriter(&buf)
page, _ := w.Create("page_0001.jpg")
_, _ = page.Write([]byte("not-a-real-jpeg"))
require.NoError(t, w.Close())
require.NoError(t, os.WriteFile(abs, buf.Bytes(), 0o644))
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Generated Comic', $2, 'comic_archive', 'application/zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
libID, rel,
).Scan(&itemID)
require.NoError(t, err)
editItem(itemID, map[string]interface{}{
"title": "Comic New Title",
"series": "Comic Series",
"series_number": 4,
"authors": []string{"Comic Writer"},
"tags": []string{"Superhero"},
})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "comicinfo", body["metadata_target"])
// ---- verify the FILE's ComicInfo.xml by parsing it ----
reader, err := zip.OpenReader(abs)
require.NoError(t, err)
var infoData []byte
for _, f := range reader.File {
if f.Name == "ComicInfo.xml" {
rc, err := f.Open()
require.NoError(t, err)
infoData, err = io.ReadAll(rc)
require.NoError(t, rc.Close())
require.NoError(t, err)
}
}
reader.Close()
require.NotEmpty(t, infoData, "ComicInfo.xml must exist inside the CBZ")
var info struct {
Title string `xml:"Title"`
Series string `xml:"Series"`
Number int `xml:"Number"`
Writer string `xml:"Writer"`
}
require.NoError(t, xml.Unmarshal(infoData, &info))
assert.Equal(t, "Comic New Title", info.Title)
assert.Equal(t, "Comic Series", info.Series)
assert.Equal(t, 4, info.Number)
assert.Equal(t, "Comic Writer", info.Writer)
// The inline rescan reads the ComicInfo back: the DB title and
// series stay the written values.
var title, series string
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT title, series FROM media_items WHERE id = $1`, itemID).Scan(&title, &series))
assert.Equal(t, "Comic New Title", title)
assert.Equal(t, "Comic Series", series)
})
t.Cleanup(func() {
_ = os.RemoveAll(filepath.Join(uploadsDir, fmt.Sprintf("test-metadata-write-%d", os.Getpid())))
})
}
@@ -0,0 +1,213 @@
package main
// Retry + scoped-scan integration tests (2026-10 Processing Issues work).
//
// - Per-issue retry re-imports a row-less import_failed ledger entry
// through the individual scanner (NOT a library scan) and closes it.
// - A missing file retries to 409 with the real reason.
// - An item-backed issue retries through the book-detail rescan
// mechanism and closes.
// - POST /api/libraries/:id/scan with folder_path must be a registered
// folder (validated, never trusted).
// - A single-folder scan must NOT run archive reconciliation for the
// library's other folders — without that guard the scoped walk would
// mark every item under the unscanned folders missing and archive them.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"bookhoard/internal/database"
)
func retryIssueViaAPI(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, issueID uuid.UUID) *httptest.ResponseRecorder {
t.Helper()
libUUID, err := uuid.FromBytes(libraryID.Bytes[:])
require.NoError(t, err)
req := httptest.NewRequest("POST", "/api/libraries/"+libUUID.String()+"/issues/"+issueID.String()+"/retry", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
return rec
}
func openIssuesFor(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID) []database.ListProcessingIssuesByLibraryRow {
t.Helper()
rows, err := setup.DB.ListProcessingIssuesByLibrary(context.Background(), libraryID)
require.NoError(t, err)
return rows
}
func seedRowlessIssue(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, relPath string) {
t.Helper()
_, err := setup.DBPool.Exec(context.Background(),
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
VALUES (NULL, $1, 'import_failed', 'seeded failure for retry test', 'error', $2)`,
libraryID, relPath)
require.NoError(t, err)
}
func TestRetryProcessingIssueRowless(t *testing.T) {
setup := setupTestServer(t)
defer setup.Close()
admin, _ := moveTestAdminID(t, setup)
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Retry Rowless %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// A real file whose import "failed" earlier (the ledger row exists,
// the media_items row does not) — exactly the production shape the
// retry button targets.
rel := "Author/retrybook.epub"
require.NoError(t, os.MkdirAll(filepath.Join(folder, "Author"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(folder, rel), []byte("dummy epub content"), 0o644))
seedRowlessIssue(t, setup, libID, filepath.ToSlash(rel))
issues := openIssuesFor(t, setup, libID)
require.Len(t, issues, 1)
issueID, err := uuid.FromBytes(issues[0].ID.Bytes[:])
require.NoError(t, err)
rec := retryIssueViaAPI(t, setup, libID, issueID)
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
// The file imported through the individual scanner...
var count int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT count(*) FROM media_items WHERE library_id = $1 AND file_path = $2`,
libID, filepath.ToSlash(rel)).Scan(&count))
require.Equal(t, 1, count, "retry must import the file")
// ...and the ledger closed (import self-heal + explicit path resolve).
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT count(*) FROM processing_issues WHERE id = $1 AND resolved AND resolved_at IS NOT NULL`,
issues[0].ID).Scan(&count))
require.Equal(t, 1, count, "successful retry must resolve the issue")
require.Len(t, openIssuesFor(t, setup, libID), 0)
// Missing file: retry answers 409 with the real reason, issue stays.
seedRowlessIssue(t, setup, libID, "Author/vanished.epub")
issues = openIssuesFor(t, setup, libID)
require.Len(t, issues, 1)
vanishedID, err := uuid.FromBytes(issues[0].ID.Bytes[:])
require.NoError(t, err)
rec = retryIssueViaAPI(t, setup, libID, vanishedID)
require.Equal(t, http.StatusConflict, rec.Code)
assert.Contains(t, rec.Body.String(), "not found on disk")
// Item-backed issue: retry runs the book-detail rescan mechanism.
itemRow, err := setup.DB.GetMediaItemByLibraryAndPath(context.Background(),
database.GetMediaItemByLibraryAndPathParams{LibraryID: libID, FilePath: filepath.ToSlash(rel)})
require.NoError(t, err)
_, err = setup.DBPool.Exec(context.Background(),
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity)
VALUES ($1, $2, 'metadata_extraction_failed', 'seeded item issue for retry test', 'warning')`,
itemRow.ID, libID)
require.NoError(t, err)
// vanished.epub's issue is still open — pick the item-backed one.
var itemIssueID uuid.UUID
for _, open := range openIssuesFor(t, setup, libID) {
if open.MediaItemID.Valid {
itemIssueID, err = uuid.FromBytes(open.ID.Bytes[:])
require.NoError(t, err)
break
}
}
require.NotEqual(t, uuid.Nil, itemIssueID, "item-backed fixture")
rec = retryIssueViaAPI(t, setup, libID, itemIssueID)
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
var resolvedCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT count(*) FROM processing_issues WHERE id = $1 AND resolved`,
itemIssueID).Scan(&resolvedCount))
require.Equal(t, 1, resolvedCount, "successful item retry must resolve the issue")
_ = admin // scanner attribution is implicit via the library owner
}
func TestScanLibraryFolderPathValidation(t *testing.T) {
setup := setupTestServer(t)
defer setup.Close()
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Scan Validate %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
post := func(folderPath string) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]string{"folder_path": folderPath})
req := httptest.NewRequest("POST", "/api/libraries/"+libID.String()+"/scan", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
return rec
}
// An unregistered path is rejected, never scanned.
rec := post("/tmp/definitely-not-registered")
require.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "not one of this library's registered folders")
// A registered folder scopes the scan and enqueues normally.
rec = post(folder)
require.Equal(t, http.StatusAccepted, rec.Code, "body: %s", rec.Body.String())
}
func TestScanSingleFolderSkipsArchiveReconciliation(t *testing.T) {
setup := setupTestServer(t)
defer setup.Close()
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folderA := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Archive Guard %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// Second registered folder: the scoped scan below covers only this one.
folderB := t.TempDir()
_, err := setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: libID,
FolderPath: folderB,
})
require.NoError(t, err)
// A healthy item under folder A.
rel := "Author/archguard.epub"
require.NoError(t, os.MkdirAll(filepath.Join(folderA, "Author"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(folderA, rel), []byte("dummy epub content"), 0o644))
scanTestFolder(t, setup, admin, folderA)
item, err := setup.DB.GetMediaItemByLibraryAndPath(ctx,
database.GetMediaItemByLibraryAndPathParams{LibraryID: libID, FilePath: filepath.ToSlash(rel)})
require.NoError(t, err)
require.False(t, item.ArchivedAt.Valid)
require.Equal(t, int32(0), item.MissingScanCount)
// Scan ONLY folder B. Without the partial-scope guard the archive
// pass would see nothing from folder A and mark this item missing
// (1/2 toward archival) — the exact false-archive the guard exists
// to prevent.
scanTestFolder(t, setup, admin, folderB)
after, err := setup.DB.GetMediaItem(ctx, item.ID)
require.NoError(t, err)
require.False(t, after.ArchivedAt.Valid, "scoped scan must not archive items under unscanned folders")
require.Equal(t, int32(0), after.MissingScanCount, "scoped scan must not mark unscanned-folder items missing")
// Control: the item's own folder scans clean as before.
scanTestFolder(t, setup, admin, folderA)
after, err = setup.DB.GetMediaItem(ctx, item.ID)
require.NoError(t, err)
require.False(t, after.ArchivedAt.Valid)
require.Equal(t, int32(0), after.MissingScanCount)
}
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSearchViewerProgress pins the search-progress batch (2026-10-27):
// the unified search row carries the REQUESTING user's reading progress
// (viewer_percentage) via a LEFT JOIN on the same user_id param the
// visibility join binds, the JSON endpoint serializes it, and the
// layout=dropdown HX branch renders templ rows with the completed check
// / partial bar. Fixture: three books in a test library; the FRESH
// regular user finished one (1.0), is mid-read on another (0.42) and
// never opened the third (NULL). The shared admin's search response is
// read but never written — their progress stays untouched.
func TestSearchViewerProgress(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
ctx := context.Background()
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Search Progress Library", false)
viewerID := getRegularUserID(t, setup.DB)
books := []struct {
title string
percentage string // "" = no progress row at all
}{
{title: "Progress Probe Alpha", percentage: "1.0"},
{title: "Progress Probe Beta", percentage: "0.42"},
{title: "Progress Probe Gamma", percentage: ""},
}
itemIDs := map[string]string{}
t.Cleanup(func() {
_, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID)
})
for _, b := range books {
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, created_at, imported_at, updated_at)
VALUES ($1, $2, $3, '2026-02-01 00:00:00+00', '2026-02-01 00:00:00+00', '2026-02-01 00:00:00+00') RETURNING id`,
libID, b.title, fmt.Sprintf("/tmp/test-search-progress/%s.epub", b.title),
).Scan(&itemID)
require.NoError(t, err)
itemIDs[b.title] = itemID
if b.percentage != "" {
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
VALUES ($1, $2, $3::float, '2026-02-02 00:00:00+00')`,
itemID, viewerID, b.percentage,
)
require.NoError(t, err)
}
}
t.Run("JSON rows carry the viewer's own percentage", func(t *testing.T) {
rows := searchJSON(t, setup, setup.RegularToken, "Progress Probe", libID)
require.Len(t, rows, 3)
byTitle := map[string]map[string]interface{}{}
for _, row := range rows {
byTitle[row["title"].(string)] = row
}
assert.Equal(t, float64(1), byTitle["Progress Probe Alpha"]["viewer_percentage"])
assert.InDelta(t, 0.42, byTitle["Progress Probe Beta"]["viewer_percentage"], 0.0001)
require.Nil(t, byTitle["Progress Probe Gamma"]["viewer_percentage"], "never-opened book must be null")
// Per-user join: the shared admin (no progress on any fixture
// book) sees null across the board — the join binds the
// REQUESTER, not a global.
adminRows := searchJSON(t, setup, setup.Token, "Progress Probe", libID)
require.Len(t, adminRows, 3)
for _, row := range adminRows {
assert.Nil(t, row["viewer_percentage"], "admin must see null for %v", row["title"])
}
})
t.Run("dropdown partial renders progress overlays", func(t *testing.T) {
req, err := http.NewRequest("GET",
setup.Server.URL+"/api/media-items/search?layout=dropdown&q=Progress+Probe&library_id="+libID, nil)
require.NoError(t, err)
req.Header.Set("Cookie", "token="+setup.RegularToken)
req.Header.Set("HX-Request", "true")
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
bodyBytes, err := io.ReadAll(resp.Body)
require.NoError(t, err)
body := string(bodyBytes)
assert.Contains(t, body, "3 results for", "count label")
// Completed book: the check overlay + title (completed ≥ 0.999).
assert.Contains(t, body, "Completed", "completed overlay")
// Partial book: the bar at 42%.
assert.Contains(t, body, "width: 42.0%", "partial progress bar")
// Server-rendered rows, one per hit.
assert.Equal(t, 3, strings.Count(body, `class="search-result-item`), "one row per result")
})
}
// searchJSON runs a search as the given token and decodes the row array
// (library-scoped: the unified search is fuzzy, so an unscoped q would
// sweep in live dev rows).
func searchJSON(t *testing.T, setup *TestServerSetup, token, query, libraryID string) []map[string]interface{} {
t.Helper()
req, err := http.NewRequest("GET",
fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(query), libraryID), nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var rows []map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
return rows
}
+257
View File
@@ -0,0 +1,257 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSeriesSortAndFilter pins the series page's sort/filter contract
// (2026-10-27 feature): the /series SSR page and the /api/series JSON
// endpoint share one sort registry (services.ResolveSeriesSort) and one
// SQL CASE cascade (GetDistinctSeries). The fixture gives three series
// DISTINCT answers per sort key so ordering bugs can't hide:
//
// created author books viewer progress
// Probe Zebra 01-03 Zeta Zed 2 1 of 2 read (01-09) → reading
// Probe Mango 01-02 Alpha One 3 3 of 3 read (01-10) → finished
// Probe Apple 01-01 Midway Two 1 none → unstarted
//
// All viewer progress belongs to the FRESH regular user
// (getRegularUserID) and cascades away with the test library — the
// shared admin's reading data is never touched.
func TestSeriesSortAndFilter(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
ctx := context.Background()
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Series Filter Library", false)
viewerID := getRegularUserID(t, setup.DB)
type seriesFixture struct {
name string
author string
createdAt string
books []struct {
title string
percentage string
lastReadAt string
}
}
fixtures := []seriesFixture{
{
name: "Probe Zebra Series", author: "Zeta Zed", createdAt: "2026-01-03 12:00:00+00",
books: []struct {
title string
percentage string
lastReadAt string
}{
{title: "Zebra Book 1", percentage: "1.0", lastReadAt: "2026-01-09 08:00:00+00"},
{title: "Zebra Book 2", percentage: "0.5", lastReadAt: "2026-01-09 08:00:00+00"},
},
},
{
name: "Probe Mango Series", author: "Alpha One", createdAt: "2026-01-02 12:00:00+00",
books: []struct {
title string
percentage string
lastReadAt string
}{
{title: "Mango Book 1", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
{title: "Mango Book 2", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
{title: "Mango Book 3", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
},
},
{
name: "Probe Apple Series", author: "Midway Two", createdAt: "2026-01-01 12:00:00+00",
books: []struct {
title string
percentage string
lastReadAt string
}{
{title: "Apple Book 1", percentage: "0", lastReadAt: ""},
},
},
}
stripComments := regexp.MustCompile(`<!--.*?-->`)
// Card headings only: the empty-state panels use different h3
// classes ("text-xl font-semibold mb-2"), and "No Series Match"
// must not read as a rendered series.
nameRegex := regexp.MustCompile(`<h3 class="font-semibold text-sm line-clamp-2"[^>]*>([^<]+)</h3>`)
renderedNames := func(query string) []string {
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+query, setup.RegularToken)
if code != 200 {
t.Logf("series request %q -> %d; body head: %.500s", query, code, body)
}
require.Equal(t, 200, code)
var names []string
for _, m := range nameRegex.FindAllStringSubmatch(stripComments.ReplaceAllString(body, ""), -1) {
names = append(names, strings.TrimSpace(m[1]))
}
return names
}
t.Cleanup(func() {
_, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID)
})
for _, fx := range fixtures {
for _, b := range fx.books {
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, series, authors, created_at, imported_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $6, $6) RETURNING id`,
libID, b.title, fmt.Sprintf("/tmp/test-series-filter/%s.epub", b.title), fx.name, []string{fx.author}, fx.createdAt,
).Scan(&itemID)
require.NoError(t, err)
if b.percentage != "0" && b.percentage != "" {
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
VALUES ($1, $2, $3::float, $4::timestamptz)`,
itemID, viewerID, b.percentage, b.lastReadAt,
)
require.NoError(t, err)
}
}
}
t.Run("default sort is recently added", func(t *testing.T) {
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames(""))
})
t.Run("every sort key orders the grid", func(t *testing.T) {
cases := []struct {
sort string
want []string
}{
{"sort=recent", []string{"Zebra", "Mango", "Apple"}},
{"sort=recent_asc", []string{"Apple", "Mango", "Zebra"}},
{"sort=name_asc", []string{"Apple", "Mango", "Zebra"}},
{"sort=name_desc", []string{"Zebra", "Mango", "Apple"}},
{"sort=author_asc", []string{"Mango", "Apple", "Zebra"}},
{"sort=author_desc", []string{"Zebra", "Apple", "Mango"}},
{"sort=books_desc", []string{"Mango", "Zebra", "Apple"}},
{"sort=books_asc", []string{"Apple", "Zebra", "Mango"}},
{"sort=progress_desc", []string{"Mango", "Zebra", "Apple"}},
{"sort=progress_asc", []string{"Apple", "Zebra", "Mango"}},
{"sort=last_read_desc", []string{"Mango", "Zebra", "Apple"}},
{"sort=last_read_asc", []string{"Zebra", "Mango", "Apple"}},
}
for _, tc := range cases {
got := renderedNames("&" + tc.sort)
require.Len(t, got, 3, tc.sort)
for i, want := range tc.want {
assert.Contains(t, got[i], want, "%s: position %d", tc.sort, i)
}
}
})
t.Run("unknown sort falls back to the default", func(t *testing.T) {
// Unknown keys resolve to "recent" in the registry; the SQL never
// interpolates the value (bound-param CASE cascade), so a hostile
// sort value is inert by construction.
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames("&sort=bogus-key"))
})
t.Run("base sort + direction toggle composes registry keys", func(t *testing.T) {
// The toolbar sends base keys + dir; defaults apply when dir is
// absent, and both directions of every base key are reachable.
cases := []struct {
query string
want []string
}{
{"sort=name", []string{"Apple", "Mango", "Zebra"}}, // name asc default
{"sort=name&dir=desc", []string{"Zebra", "Mango", "Apple"}}, // toggle flips
{"sort=books&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // books desc default, flipped
{"sort=progress&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // progress desc default, flipped
{"sort=author", []string{"Mango", "Apple", "Zebra"}}, // author asc default
// THE toggle regression (2026-10-27): "recent" is both a base
// key and a registry key — dir=asc used to be swallowed.
{"sort=recent&dir=asc", []string{"Apple", "Mango", "Zebra"}},
}
for _, tc := range cases {
got := renderedNames("&" + tc.query)
require.Len(t, got, 3, tc.query)
for i, want := range tc.want {
assert.Contains(t, got[i], want, "%s: position %d", tc.query, i)
}
}
})
t.Run("name query filters", func(t *testing.T) {
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=Mango"))
// ILIKE is case-insensitive and substring-shaped.
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=mango"))
})
t.Run("multi-field query matches authors and book titles", func(t *testing.T) {
// Author of Mango is "Alpha One" — the query no longer needs the
// separate author box.
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=alpha+one"))
// "Zebra Book 2" is a book TITLE inside the Zebra series: the
// series must surface for a title match too.
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&q=zebra+book+2"))
// Nothing matches anywhere.
assert.Empty(t, renderedNames("&q=nobody-nowhere"))
})
t.Run("author param (API back-compat) filters on any book's author", func(t *testing.T) {
// The toolbar no longer renders a separate author box (the
// multi-field q covers authors), but the param stays live for
// the JSON API and deep links.
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&author=alpha"))
assert.Empty(t, renderedNames("&author=nobody"))
})
t.Run("status filter matches the viewer's progress", func(t *testing.T) {
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&status=finished"), "all books finished")
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&status=reading"), "some book mid-read")
assert.Equal(t, []string{"Probe Apple Series"}, renderedNames("&status=unstarted"), "no progress anywhere")
})
t.Run("filters compose", func(t *testing.T) {
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&q=probe&status=reading"))
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=probe&status=finished&sort=progress_desc"))
})
t.Run("cards carry the viewer's read count", func(t *testing.T) {
_, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID, setup.RegularToken)
assert.Contains(t, body, "3 of 3 read")
assert.Contains(t, body, "1 of 2 read")
assert.NotContains(t, body, "of 1 read", "unstarted series must not render a progress line")
})
t.Run("JSON API shares the sort/filter contract", func(t *testing.T) {
req, err := http.NewRequest("GET", setup.Server.URL+"/api/series?library_id="+libID+"&sort=author_asc&status=finished", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
var payload struct {
Series []struct {
Name string `json:"name"`
ReadCount int64 `json:"read_count"`
} `json:"series"`
Total int `json:"total"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
require.Len(t, payload.Series, 1)
assert.Equal(t, "Probe Mango Series", payload.Series[0].Name)
assert.Equal(t, int64(3), payload.Series[0].ReadCount)
assert.Equal(t, 1, payload.Total)
})
}
+189
View File
@@ -0,0 +1,189 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// unlinkedBooksFixture is one user + device + two queue rows. The regular
// user's cleanup cascades: users → devices → unlinked_books.
type unlinkedBooksFixture struct {
setup *TestServerSetup
owner UserTestData
ownerTok string
deviceID pgtype.UUID
contentA string
contentB string
rowA pgtype.UUID
rowB pgtype.UUID
}
func setupUnlinkedBooksFixture(t *testing.T) *unlinkedBooksFixture {
f := &unlinkedBooksFixture{setup: setupTestServer(t), contentA: "test-content-aaa", contentB: "test-content-bbb"}
f.owner = createRegularUserOnce(t, f.setup.DB)
f.ownerTok = loginUserWithCredentials(t, f.setup.Server, f.owner.Email, f.owner.Password)
device, err := f.setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
UserID: pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true},
DeviceName: "test-unlinked-reader",
DeviceType: "koreader",
DeviceIdentifier: "test-unlinked-" + uuid.New().String()[:8],
AuthToken: "dev_test_" + uuid.New().String(),
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "device for unlinked-books tests")
f.deviceID = device.ID
f.rowA = f.seedRow(t, f.contentA, "Test Book A")
f.rowB = f.seedRow(t, f.contentB, "Test Book B")
return f
}
// seedRow inserts one queue row for the fixture device.
func (f *unlinkedBooksFixture) seedRow(t *testing.T, contentID, title string) pgtype.UUID {
row, err := f.setup.DB.CreateUnlinkedBook(context.Background(), database.CreateUnlinkedBookParams{
DeviceID: f.deviceID,
ContentID: contentID,
Title: pgtype.Text{String: title, Valid: true},
})
require.NoError(t, err, "seed unlinked_books row")
return row.ID
}
// doAction fires an htmx-style page action as the given user.
func (f *unlinkedBooksFixture) doAction(t *testing.T, token, method, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Cookie", "token="+token)
req.Header.Set("HX-Request", "true")
rec := httptest.NewRecorder()
f.setup.Server.Config.Handler.ServeHTTP(rec, req)
return rec
}
func (f *unlinkedBooksFixture) unresolvedCount(t *testing.T) int {
rows, err := f.setup.DB.ListUnresolvedUnlinkedBooksForUser(context.Background(),
pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
require.NoError(t, err)
return len(rows)
}
func (f *unlinkedBooksFixture) ignoredIDs(t *testing.T) []uuid.UUID {
rows, err := f.setup.DB.ListIgnoredUnlinkedBooksForUser(context.Background(),
pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
require.NoError(t, err)
ids := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
ids = append(ids, uuid.UUID(r.ID.Bytes))
}
return ids
}
// TestUnlinkedBooksIgnoreLifecycle pins the soft-dismiss contract: hidden
// from the page list, badge count, and per-device API; preserved across
// the sync upsert; reversible via unignore. Hard delete is covered too.
func TestUnlinkedBooksIgnoreLifecycle(t *testing.T) {
f := setupUnlinkedBooksFixture(t)
ctx := context.Background()
pathA := "/unlinked-books/" + uuid.UUID(f.rowA.Bytes).String()
pathB := "/unlinked-books/" + uuid.UUID(f.rowB.Bytes).String()
t.Run("ignore hides the row everywhere", func(t *testing.T) {
rec := f.doAction(t, f.ownerTok, "POST", pathA+"/ignore")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
// The htmx swap re-renders the whole section partial.
assert.Contains(t, rec.Body.String(), `id="unlinked-section"`)
assert.Equal(t, 1, f.unresolvedCount(t), "row B remains queued")
ignored := f.ignoredIDs(t)
require.Len(t, ignored, 1)
assert.Equal(t, uuid.UUID(f.rowA.Bytes), ignored[0])
count, err := f.setup.DB.CountUnresolvedUnlinkedBooksForUser(ctx, pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
require.NoError(t, err)
assert.Equal(t, int64(1), count, "dashboard badge must still count row B only")
perDevice, err := f.setup.DB.GetUnlinkedBooksByDevice(ctx, f.deviceID)
require.NoError(t, err)
assert.Len(t, perDevice, 1, "per-device API must hide the ignored row")
deviceCount, err := f.setup.DB.CountUnlinkedBooks(ctx, f.deviceID)
require.NoError(t, err)
assert.Equal(t, int64(1), deviceCount)
})
t.Run("device sync refresh preserves the ignore", func(t *testing.T) {
// The exact query the KOReader sync path runs on every re-sync.
_, err := f.setup.DB.UpsertUnlinkedBook(ctx, database.UpsertUnlinkedBookParams{
DeviceID: f.deviceID,
ContentID: f.contentA,
Title: pgtype.Text{String: "Test Book A (restored metadata)", Valid: true},
})
require.NoError(t, err)
row, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowA)
require.NoError(t, err)
assert.True(t, row.IgnoredAt.Valid, "upsert must never clear ignored_at")
assert.Equal(t, "Test Book A (restored metadata)", row.Title.String, "upsert still refreshes metadata")
assert.Equal(t, 1, f.unresolvedCount(t), "ignored row must not re-enter the queue")
})
t.Run("unignore restores the row to the queue", func(t *testing.T) {
rec := f.doAction(t, f.ownerTok, "POST", pathA+"/unignore")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Equal(t, 2, f.unresolvedCount(t))
assert.Empty(t, f.ignoredIDs(t))
row, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowA)
require.NoError(t, err)
assert.False(t, row.IgnoredAt.Valid)
})
t.Run("delete removes the row outright", func(t *testing.T) {
rec := f.doAction(t, f.ownerTok, "DELETE", pathB)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
_, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowB)
assert.Error(t, err, "hard-deleted row must be gone")
assert.Equal(t, 1, f.unresolvedCount(t))
})
t.Run("unknown and malformed ids are rejected", func(t *testing.T) {
rec := f.doAction(t, f.ownerTok, "DELETE", "/unlinked-books/"+uuid.New().String())
assert.Equal(t, http.StatusNotFound, rec.Code)
rec = f.doAction(t, f.ownerTok, "POST", "/unlinked-books/not-a-uuid/ignore")
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
}
// TestUnlinkedBooksOwnership pins row → device → user authorization:
// another user's queue rows are untouchable, ignored or deleted.
func TestUnlinkedBooksOwnership(t *testing.T) {
f := setupUnlinkedBooksFixture(t)
stranger := createRegularUserOnce(t, f.setup.DB)
strangerTok := loginUserWithCredentials(t, f.setup.Server, stranger.Email, stranger.Password)
pathA := "/unlinked-books/" + uuid.UUID(f.rowA.Bytes).String()
rec := f.doAction(t, strangerTok, "POST", pathA+"/ignore")
assert.Equal(t, http.StatusForbidden, rec.Code)
row, err := f.setup.DB.GetUnlinkedBookByID(context.Background(), f.rowA)
require.NoError(t, err)
assert.False(t, row.IgnoredAt.Valid, "foreign ignore must not land")
rec = f.doAction(t, strangerTok, "DELETE", pathA)
assert.Equal(t, http.StatusForbidden, rec.Code)
_, err = f.setup.DB.GetUnlinkedBookByID(context.Background(), f.rowA)
assert.NoError(t, err, "foreign delete must not land")
}
+89
View File
@@ -667,3 +667,92 @@ func TestUpdatePasswordSelfChange(t *testing.T) {
assert.Contains(t, rec.Body.String(), "current password required")
})
}
// TestProfileKeepScreenAwake pins the keep_screen_awake user preference
// end to end: schema default, the "true"/"false" form dropdown contract,
// JSON-client omission semantics ("" = untouched), and rejection of
// anything else. Uses a dedicated user — never the shared admin.
func TestProfileKeepScreenAwake(t *testing.T) {
// getProfile fetches the JSON profile and returns keep_screen_awake.
getProfile := func(t *testing.T, setup *TestServerSetup, token string) bool {
req := httptest.NewRequest("GET", "/api/auth/profile", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var body struct {
KeepScreenAwake bool `json:"keep_screen_awake"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
return body.KeepScreenAwake
}
putProfile := func(t *testing.T, setup *TestServerSetup, token string, fields map[string]interface{}) *httptest.ResponseRecorder {
jsonData, _ := json.Marshal(fields)
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
return rec
}
t.Run("fresh user defaults to true", func(t *testing.T) {
setup := setupTestServer(t)
selfUser := createRegularUserOnce(t, setup.DB)
token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password)
row, err := setup.DB.GetUser(context.Background(), pgtype.UUID{Bytes: selfUser.ID, Valid: true})
require.NoError(t, err)
assert.True(t, row.KeepScreenAwake, "schema default must keep the column TRUE")
assert.True(t, getProfile(t, setup, token), "profile API must report the default")
})
t.Run("false persists and reads back", func(t *testing.T) {
setup := setupTestServer(t)
selfUser := createRegularUserOnce(t, setup.DB)
token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password)
rec := putProfile(t, setup, token, map[string]interface{}{"keep_screen_awake": "false"})
require.Equal(t, http.StatusOK, rec.Code)
assert.False(t, getProfile(t, setup, token))
row, err := setup.DB.GetUser(context.Background(), pgtype.UUID{Bytes: selfUser.ID, Valid: true})
require.NoError(t, err)
assert.False(t, row.KeepScreenAwake)
})
t.Run("flipping back to true persists", func(t *testing.T) {
setup := setupTestServer(t)
selfUser := createRegularUserOnce(t, setup.DB)
token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password)
require.Equal(t, http.StatusOK, putProfile(t, setup, token,
map[string]interface{}{"keep_screen_awake": "false"}).Code)
require.Equal(t, http.StatusOK, putProfile(t, setup, token,
map[string]interface{}{"keep_screen_awake": "true"}).Code)
assert.True(t, getProfile(t, setup, token))
})
t.Run("request omitting the field leaves it untouched", func(t *testing.T) {
setup := setupTestServer(t)
selfUser := createRegularUserOnce(t, setup.DB)
token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password)
// JSON clients (the Android app) sync other profile fields
// without knowing this one — the default-on preference must
// survive such a save.
rec := putProfile(t, setup, token, map[string]interface{}{"theme": "nord"})
require.Equal(t, http.StatusOK, rec.Code)
assert.True(t, getProfile(t, setup, token))
})
t.Run("invalid value is rejected", func(t *testing.T) {
setup := setupTestServer(t)
selfUser := createRegularUserOnce(t, setup.DB)
token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password)
rec := putProfile(t, setup, token, map[string]interface{}{"keep_screen_awake": "maybe"})
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.True(t, getProfile(t, setup, token), "rejected save must not change the preference")
})
}
+20 -1
View File
@@ -42,6 +42,11 @@ CREATE TABLE IF NOT EXISTS users (
-- mirrors the per-book table's column.
ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEFAULT 1.0;
-- Keep-screen-awake preference: when true, the web reader and audiobook
-- player request a Screen Wake Lock so the display doesn't blank/lock
-- mid-session. On by default; the profile page's On/Off dropdown owns it.
ALTER TABLE users ADD COLUMN IF NOT EXISTS keep_screen_awake BOOLEAN NOT NULL DEFAULT TRUE;
-- Create system_settings table
CREATE TABLE IF NOT EXISTS system_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -169,7 +174,7 @@ CREATE TABLE IF NOT EXISTS media_items (
language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1)
edition VARCHAR(100), -- Edition information
page_count INTEGER, -- Total page count
genre VARCHAR(100), -- Genre classification
genre TEXT, -- Genre classification (smart-genre shelf OR raw tag free text — Audible category strings exceed 100 chars)
copyright_year INTEGER, -- Copyright/publication year
goodreads_id VARCHAR(50), -- Goodreads identifier
openlibrary_id VARCHAR(50), -- Open Library identifier
@@ -274,6 +279,14 @@ CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
-- Widen genre for existing databases: the audio tag fallback stores the
-- raw file genre atom (Audible category strings exceed 100 chars —
-- e.g. "Politicians, Presidents & Heads of State, United States, ..."),
-- and VARCHAR(100) rejected the whole import/update at the database
-- layer (SQLSTATE 22001). VARCHAR -> TEXT is a metadata-only change,
-- safe to re-run (media_highlights position widening precedent).
ALTER TABLE media_items ALTER COLUMN genre TYPE TEXT;
-- Audiobook editorial facts (A2a): narrators are user-editable first-class
-- (search/filter/badge surface); abridged rides along; audio_metadata keeps
-- the scan-derived tech record (duration/codec/chapters).
@@ -1491,6 +1504,12 @@ CREATE TABLE IF NOT EXISTS unlinked_books (
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Soft-dismiss for unlinked_books: ignored rows stay on the device's sync
-- queue (UpsertUnlinkedBook keeps refreshing their file metadata, and must
-- never clear this column) but are hidden from the page list, badge count,
-- and per-device API. Reversible via UnignoreUnlinkedBook.
ALTER TABLE unlinked_books ADD COLUMN IF NOT EXISTS ignored_at TIMESTAMP WITH TIME ZONE;
CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
-- One queue row per (device, document): the sync ingest upserts on this pair.
+20 -2
View File
@@ -37,14 +37,15 @@ These are the response SHAPES (JSON key sets, not values) frozen by the
| `CollectionsJSON` | `GET /api/collections/:id` (all 4 branches) | envelope `id, user_id, name, description, color, icon, auto_assign_rules, view_settings, books[], book_count, is_smart, created_at`; books = `BookInfo` |
| | `POST /api/collections/preview` | bare `{items: BookInfo[]}` |
| `SeriesJSON` | `GET /api/series/books?name=&library_id=` | `{name, library_id, books: BookInfo[], total}` |
| `SearchJSON` | `GET /api/media-items/search?q=&library_id=` | top-level array of `SearchMediaItemsUnifiedRow` (47 keys) |
| `SearchJSON` | `GET /api/media-items/search?q=&library_id=` | top-level array of `SearchMediaItemsUnifiedRow` (47 keys + viewer_percentage) |
| `DashboardSections` | `GET /api/dashboard/sections` | `{sections[]: {id, collection_id, is_system, title, description, icon, items: BookInfo[], view_all_url, priority}}` |
| `MediaListBothBranches` | `GET /api/media-items` (unscoped) | `{data: [38-key resolved map]}` (intentionally divergent from the scoped branch until the row-shape collapse batch) |
| | `GET /api/media-items?library_id=&sort=` | `{data: [raw ListMediaItemsSortedRow, 70 sqlc keys]}` |
| `SSRPages` | `/bookshelf`, `/collections`, `/collections/:id`, `/series`, `/series/detail`, `/tags/detail` | 200 + stable title/substring markers |
`BookInfo` (every card surface): `media_item_id, title, author,
cover_image_path, has_conflict, format_group, badge`.
cover_image_path, has_conflict, format_group, badge, viewer_percentage,
library_name`.
## Changelog (additive keys per batch)
@@ -414,3 +415,20 @@ cover_image_path, has_conflict, format_group, badge`.
BEFORE Rescan All — force rescan stamps books with the current
setting, so bump-first + force = one pass of work; force-first +
bump = the trickle re-derives everything again (wasted, not harmful).
## 2026-10-27 — Search-progress batch: viewer progress on cards
- **`SearchJSON`**: `SearchMediaItemsUnifiedRow` + `viewer_percentage`
(float 0..1, NULL when the requester never opened the book). The
query LEFT JOINs `reading_progress` on the SAME `user_id` param the
library-visibility join already binds — one requester param, no new
wire inputs; the join is row-neutral (UNIQUE(media_item, user)).
- **`BookInfo`** (CollectionsJSON / SeriesJSON / preview /
DashboardSections / search cards): + `viewer_percentage` (JSON null
when nil) and + `library_name` (empty on feeds that don't load it —
only the search-unified adapter populates both today). Adapters
other than BookCardsFromUnified keep emitting null/""; templ cards
render the progress bar/completed check only when populated.
- The header-search dropdown now renders the templ `SearchResults`
partial (layout=dropdown on the same endpoint, HX-Request) instead
of the retired innerHTML renderer in web/src/search.ts — same
endpoint, same auth, content server-rendered.
@@ -0,0 +1,41 @@
---
method: POST
path: /api/libraries/:id/issues/:issueId/retry
auth: jwt+admin
area: libraries
---
<!-- source: internal/handlers/processing_issues.go RetryProcessingIssue -->
# Retry Processing Issue
Re-runs the work that produced an open issue through the individual
scanner — deliberately NOT a library-wide rescan. Two shapes:
- **Item-backed issues** (media item exists): the item is rescanned via
the same mechanism as the book-detail Rescan action — self-resolving
folders, multi-file audiobook routing, write-after-success guards.
- **Row-less `import_failed` issues** (no media item): the single file
is re-imported through the scanner's standalone per-file path.
On success the issue is closed. On failure the response carries the REAL
error (e.g. the underlying SQL failure), so the UI can show why the issue
still exists. The issue's library must be in the requester's visible set.
## Example response
```json example-response
{
"success": true,
"message": "File imported; issue resolved"
}
```
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "Invalid issue ID"}` | malformed path param |
| 403 | — | issue's library not in requester's visible set |
| 404 | `{"error": "Issue not found"}` | no such issue |
| 409 | `{"error": "Issue is already resolved"}` / `{"error": "media file not found on disk: …"}` | nothing to retry, or the file vanished |
| 500 | `{"error": "…"}` | the retried work failed — body carries the underlying error |
+9 -2
View File
@@ -8,10 +8,17 @@ area: libraries
# Scan Library
Enqueues a full scan of one library's configured folders. The library id comes
Enqueues a scan of one library's configured folders. The library id comes
from the path — no request body is needed (the generic `POST /api/scanner/scan`
variant is the one that takes `folder_paths`).
**Per-folder scope**: an optional body of `{"folder_path": "…"}` restricts the
scan to ONE of the library's registered folders (the per-folder Scan buttons
in the library panel). The path must be a registered folder — anything else is
a **400**. A scoped scan never runs archive reconciliation for the library's
other folders, so items outside the scanned folder cannot be marked missing
by it.
Scans are **coalesced**: if a scan for this library is already pending or
running you get **409** with the active job id instead of a second scan.
@@ -29,7 +36,7 @@ running you get **409** with the active job id instead of a second scan.
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` | malformed id / empty library |
| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` / `{"error": "folder_path is not one of this library's registered folders"}` | malformed id / empty library / unregistered folder scope |
| 404 | `{"error": "library not found or has no folders"}` | unknown library |
| 409 | `{"error": "a scan for this library is already pending or running", "active_job_id": "..."}` | scan coalescing |
| 500 | `{"error": "failed to enqueue scan job: ..."}` | queue failure |
+2 -1
View File
@@ -35,7 +35,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
"username": "john",
"first_name": "John",
"last_name": "Doe",
"role": "user"
"role": "user",
"keep_screen_awake": true
}
```
+10 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Bookhoard API
version: '2026.10.24'
version: '2026.10.27'
description: |
Generated from the canonical API docs (docs/developer/api/**) by
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
@@ -1141,6 +1141,15 @@ paths:
responses:
'200':
description: Success
"/api/libraries/{id}/issues/{issueId}/retry":
post:
tags: [libraries]
summary: "Retry Processing Issue"
security:
- bearerAuth: []
responses:
'200':
description: Success
"/api/libraries/{id}/issues/{issueId}/{mediaItemId}/resolve":
post:
tags: [libraries]
+29
View File
@@ -298,6 +298,35 @@ A: Calibre Web uses the same `metadata.opf` files, so yes - Bookhoard can scan a
A: Bookhoard has its own sorting logic and doesn't import Calibre sort fields. This allows for consistent sorting across all books.
## Writing metadata back to files (admin)
The book page's metadata editor has a **Write to file** action alongside
Save. Save updates Bookhoard's database only; Write to file persists the
currently saved values into the artifact on disk, then re-extracts so
everything stays in sync:
- **EPUB/KEPUB** — the OPF embedded in the book is rewritten in place
(cover, chapters and reading position are untouched). The file's
checksum changes, so device syncs (Kobo, KOReader) re-key the book on
their next sync.
- **Single-file audio (MP3/FLAC/M4A/M4B)** — the container's own tags
are rewritten (audio streams copied untouched, chapters and cover art
preserved). Only fields the format can carry are written: title,
subtitle (MP3/FLAC), authors, narrators (composer tag — Audible's
convention), series, date and genre.
- **CBZ** — a ComicInfo.xml is written inside the archive (injected or
replaced). CBR/CB7 archives can't be rewritten in place, so they take
the sidecar path.
- **Everything else** (PDF, folder books, CBR/CB7) — a Calibre-style
sidecar is written next to the file:
`metadata.opf` when the book is alone in its folder (Calibre's own
convention, so Calibre reads it too), or `<filename>.opf` when several
books share a folder, so one book's sidecar can never leak onto its
neighbours.
The write mirrors exactly what the scanner reads back, and the original
file is never touched if anything fails (temp file + atomic rename).
## Resources
- [Calibre Website](https://calibre-ebook.com/)
+194
View File
@@ -0,0 +1,194 @@
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
const BASE = "http://localhost:8765";
const STAMP = Date.now();
const EMAIL = `e2e-search-${STAMP}@example.com`;
const USERNAME = `e2e-search-${STAMP}`;
const PASSWORD = "E2e-Playwright-2026!";
let token = "";
const consoleFailures: string[] = [];
function watchPage(page: Page): void {
page.on("pageerror", (err) => {
consoleFailures.push(`pageerror: ${err.message}`);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleFailures.push(`console.error: ${msg.text()}`);
}
});
}
function expectNoClientErrors(): void {
const bad = consoleFailures.filter((f) =>
/(is not defined|is not a function|headerSearch|alpine)/i.test(f),
);
expect(bad, `uncaught client errors:\n${bad.join("\n")}`).toEqual([]);
}
async function apiLogin(request: APIRequestContext): Promise<string> {
const res = await request.post("/api/auth/login", {
data: { login: EMAIL, password: PASSWORD },
});
expect(res.status(), "login should succeed").toBe(200);
const body = await res.json();
return body.access_token as string;
}
test.describe.configure({ mode: "serial" });
test.describe("Header search + audio scrubber (client wiring)", () => {
let page: Page;
test.beforeAll(async ({ browser, request }) => {
const reg = await request.post("/api/auth/register", {
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
});
expect([201, 200, 409]).toContain(reg.status());
token = await apiLogin(request);
expect(token).toBeTruthy();
page = await browser.newPage();
watchPage(page);
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
});
test.afterAll(async () => {
expectNoClientErrors();
await page?.close();
});
test("header search dropdown opens on typing (regression: tree-shaken module)", async () => {
// Drive the header on TWO pages: the bundle is shared, but this
// catches any page-scoped wiring surprise.
for (const pagePath of ["/bookshelf", "/series"]) {
await page.goto(pagePath);
const input = page.locator("#header-search");
await expect(input).toBeVisible();
// The regression this pins: search.ts (which registers the
// headerSearch Alpine component) was tree-shaken from the bundle,
// so typing did nothing — no request, no dropdown. Type with REAL
// key events (not fill) and assert the request carries the query.
await input.click();
await input.pressSequentially("algo", { delay: 90 });
const requestPromise = page.waitForRequest(
(req) =>
req.url().includes("/api/media-items/search") &&
req.url().includes("q=algo") &&
(req.headers()["hx-request"] === "true" || req.headers()["hx-request"] === "true"),
{ timeout: 10_000 },
);
const req = await requestPromise;
expect(req.url()).toContain("layout=dropdown");
const results = page.locator("#search-results");
await expect(results).toBeVisible({ timeout: 10_000 });
await expect(results.locator(".search-result-item").first()).toBeVisible({
timeout: 10_000,
});
// Escape closes the dropdown (keyboard contract).
await page.keyboard.press("Escape");
await expect(results).toBeHidden();
}
});
test("audio scrubber: grabbing the handle does not teleport (plain and Shift)", async ({ request }) => {
// Find an audiobook in the shared dev library (plain media list,
// then filter client-side — the search endpoint's facet behavior
// with empty q isn't a listing API).
const res = await request.get("/api/media-items", {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status()).toBe(200);
const body = (await res.json()) as { data?: Array<{ id: string; format_group?: string }> };
const rows = (body.data ?? []).filter((r) => r.format_group === "audiobook");
test.skip(rows.length === 0, "no audiobooks in the dev library");
const bookId = rows[0].id;
await page.goto(`/readers/${bookId}`);
const scrubArea = page.locator("#scrub-area");
await expect(scrubArea).toBeVisible();
// The fresh user has never played this book — park the playhead at
// 30% via the progress API so the grab tests run mid-book.
const put = await request.put(`/api/media-items/${bookId}/progress`, {
headers: { Authorization: `Bearer ${token}` },
data: { percentage: 0.3, device_id: "e2e-scrubber" },
});
expect([200, 204]).toContain(put.status());
await page.reload();
await expect(scrubArea).toBeVisible();
const box = await scrubArea.boundingBox();
test.skip(!box, "scrub bar not laid out");
const y = box!.y + box!.height / 2;
// Where is the thumb right now? The native range input mirrors the
// playhead; read its aria-valuenow + max to compute the thumb X.
const thumbX = await page.evaluate(() => {
const input = document.getElementById("audio-scrub") as HTMLInputElement;
const area = document.getElementById("scrub-area")!;
const rect = area.getBoundingClientRect();
const value = Number(input.value || 0);
const max = Number(input.max || 1);
return rect.left + (value / max) * rect.width;
});
const readScrubValue = () =>
page.evaluate(() => Number((document.getElementById("audio-scrub") as HTMLInputElement).value));
const before = await readScrubValue();
expect(before).toBeGreaterThan(0);
// --- plain grab: pointerdown ON the thumb must not move it ---
await page.mouse.move(thumbX, y);
await page.mouse.down();
await page.waitForTimeout(150);
const atGrab = await readScrubValue();
expect(Math.abs(atGrab - before)).toBeLessThan(
Math.max(1, before * 0.0005),
"grabbing the handle must not jump the playhead",
);
// Drag left by 60px: the handle follows RELATIVELY.
await page.mouse.move(thumbX - 60, y, { steps: 5 });
await page.waitForTimeout(100);
const afterDrag = await readScrubValue();
expect(afterDrag).toBeLessThan(atGrab, "dragging left scrubs backward");
await page.mouse.up();
await page.waitForTimeout(400); // seek commits
// --- Shift+grab: the precision rail appears, no teleport ---
const railBefore = await readScrubValue();
// Re-locate the thumb after the commit.
const thumbX2 = await page.evaluate(() => {
const input = document.getElementById("audio-scrub") as HTMLInputElement;
const area = document.getElementById("scrub-area")!;
const rect = area.getBoundingClientRect();
const value = Number(input.value || 0);
const max = Number(input.max || 1);
return rect.left + (value / max) * rect.width;
});
await page.keyboard.down("Shift");
await page.mouse.move(thumbX2, y);
await page.mouse.down();
await page.waitForTimeout(150);
const railAtGrab = await readScrubValue();
expect(Math.abs(railAtGrab - railBefore)).toBeLessThan(
Math.max(1, railBefore * 0.0005),
"Shift+grab must not teleport either",
);
// The rail is the feedback for Shift+grab — it must be visible.
await expect(page.locator('div[x-show="railVisible"]')).toBeVisible();
await page.keyboard.up("Shift");
await page.mouse.up();
});
});
+160
View File
@@ -0,0 +1,160 @@
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
import { execSync } from "child_process";
const BASE = "http://localhost:8765";
const STAMP = Date.now();
const EMAIL = `e2e-setfb-${STAMP}@example.com`;
const USERNAME = `e2e-setfb-${STAMP}`;
const PASSWORD = "E2e-Playwright-2026!";
// Settings-page save feedback: every save surface must acknowledge the
// click (toast) and the scan form must NOT swap the handler's JSON body
// over its card (the pre-toast behavior — raw JSON replaced the form).
//
// Dev-state safety: every success save re-submits the value ALREADY in
// the input (read from the page first), so nothing in the shared dev DB
// changes; the error-path request is rejected server-side before any
// write.
let token = "";
let page = null as Page | null;
// system_config.updated_by audit restore: the config save handler stamps
// the requesting admin's id onto system_config rows. The harness cleanup
// deletes transient example.com users, and this FK (no cascade) would
// block the delete and fail unrelated integration tests. Capture before
// any save, restore after.
let e2eUserID = "";
let origUpdatedBy = "";
function psql(sql: string): string {
const flat = sql.replace(/\s+/g, " ");
return execSync(
`docker exec bookhoard_db psql -U postgres -d bookhoard -tAc ${JSON.stringify(flat)}`,
)
.toString()
.trim();
}
async function apiLogin(request: APIRequestContext): Promise<string> {
const res = await request.post("/api/auth/login", {
data: { login: EMAIL, password: PASSWORD },
});
expect(res.status(), "login should succeed").toBe(200);
return (await res.json()).access_token as string;
}
test.describe.configure({ mode: "serial" });
test.describe("Settings save feedback (toasts + disabled buttons)", () => {
test.beforeAll(async ({ browser, request }) => {
// Fresh user, promoted to admin in the dev DB (the harness cleanup
// sweeps transient example.com users afterwards).
const reg = await request.post("/api/auth/register", {
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
});
expect([201, 200, 409]).toContain(reg.status());
psql(`UPDATE users SET role='admin' WHERE email='${EMAIL}'`);
token = await apiLogin(request);
expect(token).toBeTruthy();
e2eUserID = psql(`SELECT id FROM users WHERE email='${EMAIL}'`);
origUpdatedBy = psql(`SELECT COALESCE(updated_by::text, '') FROM system_config WHERE key='base_url'`);
page = await browser.newPage();
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
await page.goto("/admin/settings");
await expect(page.locator("h1")).toHaveText("System Settings");
});
test.afterAll(async () => {
await page?.close();
// Hand system_config.updated_by back to whoever held it (or NULL),
// so the transient user can be swept.
if (e2eUserID) {
const restore = origUpdatedBy
? `'${origUpdatedBy}'::uuid`
: "NULL";
psql(`UPDATE system_config SET updated_by = ${restore} WHERE updated_by = '${e2eUserID}'::uuid`);
}
});
test("Base URL save shows a success toast — twice (twin-form drift pin)", async () => {
const input = page.locator("#settings-form input[name='base_url']");
await expect(input).toBeVisible();
// First save goes through the templ form…
await page.locator("#settings-form button[type='submit']").first().click();
await expect(page.getByText("Settings saved", { exact: true })).toBeVisible({
timeout: 10_000,
});
// …the second through the Go-source twin the handler swaps in after
// the first save. If the twin ever drops data-settings-save (the
// handlers→templates import cycle is why the twin exists), THIS is
// the assertion that fails.
await page.waitForTimeout(300); // let the swap settle
await expect(page.locator("#settings-form")).toBeVisible();
await page.locator("#settings-form button[type='submit']").first().click();
await expect(page.getByText("Settings saved", { exact: true })).toBeVisible({
timeout: 10_000,
});
});
test("Scan settings save shows a toast and never swaps raw JSON over the card", async () => {
const section = page.locator("#scan-settings-section");
await expect(section).toBeVisible();
await section.locator("button[type='submit']").click();
await expect(page.getByText("Scan settings saved", { exact: true })).toBeVisible({
timeout: 10_000,
});
// The regression: the scan handler responds JSON; with the old
// hx-swap="outerHTML" the card was replaced by the literal JSON text.
await expect(section).toBeVisible();
await expect(section.locator("form")).toBeVisible();
const body = await page.locator("body").innerText();
expect(body).not.toContain("scan settings updated successfully");
});
test("Tunable row save shows a toast and the per-row status span", async () => {
const row = page.locator("form[data-settings-save='tunable']").first();
await expect(row).toBeVisible();
const key = await row.locator("input[name='key']").getAttribute("value");
expect(key).toBeTruthy();
await row.locator("button[type='submit']").click();
await expect(page.getByText("Setting saved", { exact: true })).toBeVisible({
timeout: 10_000,
});
// Server-driven per-row status (kept alongside the toast; it carries
// the "restart required" nuance).
await expect(page.locator(`#status-${key}`)).toContainText("Saved", {
timeout: 10_000,
});
});
test("Invalid value shows an error toast and the form survives", async () => {
// Inject a genuinely invalid option and select it: setting a select
// to a value with no matching option silently serializes as NO field
// at all (no selected option → nothing submitted), which would save
// fine instead of failing. With a real option present, the server
// rejects with 400 {"error":"invalid timezone"} before any write.
await page.evaluate(() => {
const select = document.getElementById("default_timezone") as HTMLSelectElement;
const opt = document.createElement("option");
opt.value = "Not/AZone";
opt.textContent = "E2E invalid";
select.appendChild(opt);
select.value = "Not/AZone";
});
await page.locator("#settings-form button[type='submit']").first().click();
await expect(page.getByText("Save failed: invalid timezone")).toBeVisible({
timeout: 10_000,
});
// htmx does not swap non-2xx bodies: the form is intact.
await expect(page.locator("#settings-form")).toBeVisible();
await expect(page.locator("#settings-form input[name='base_url']")).toBeVisible();
});
});
+259
View File
@@ -0,0 +1,259 @@
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
import { execSync } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
const REPO = process.cwd(); // uploads/ here is bind-mounted at /app/uploads
const BASE = "http://localhost:8765";
const STAMP = Date.now();
const EMAIL = `e2e-meta-${STAMP}@example.com`;
const USERNAME = `e2e-meta-${STAMP}`;
const PASSWORD = "E2e-Playwright-2026!";
// Host-side upload dir (bind-mounted into the app container).
const UPLOADS_HOST = path.join(REPO, "uploads", `e2e-meta-${STAMP}`);
const UPLOADS_CONT = `/app/uploads/e2e-meta-${STAMP}`;
interface FormatSeed {
ext: string;
group: string;
mime: string;
id?: string;
}
// Every write-capable file type, through the REAL admin UI, verified by
// reading each artifact back independently afterwards (this test IS the
// "choose each file type, then check the file's metadata yourself"
// contract):
//
// epub → embedded OPF splice; verify by parsing the OPF XML
// pdf → Calibre metadata.opf sidecar (lone book); parse XML
// cbz → ComicInfo.xml injected; parse XML
// mp3/flac/m4a/m4b → ffprobe the rewritten tags
const FORMATS: FormatSeed[] = [
{ ext: "epub", group: "ebook", mime: "application/epub+zip" },
{ ext: "pdf", group: "ebook", mime: "application/pdf" },
{ ext: "cbz", group: "comic_archive", mime: "application/zip" },
{ ext: "mp3", group: "audiobook", mime: "audio/mpeg" },
{ ext: "flac", group: "audiobook", mime: "audio/flac" },
{ ext: "m4a", group: "audiobook", mime: "audio/mp4" },
{ ext: "m4b", group: "audiobook", mime: "audio/mp4" },
];
let token = "";
let libraryID = "";
function psql(sql: string): string {
// Collapse whitespace (JSON.stringify would bind newlines as literal
// "\n" through the shell and break the SQL).
const flat = sql.replace(/\s+/g, " ");
return execSync(
`docker exec bookhoard_db psql -U postgres -d bookhoard -tAc ${JSON.stringify(flat)}`,
)
.toString()
.trim();
}
function run(cmd: string): string {
return execSync(cmd, { encoding: "utf8" }).trim();
}
function writePython(script: string): void {
run(`python3 - <<'PYEOF'\n${script}\nPYEOF`);
}
function writeEPUB(dest: string, title: string): void {
const script = `import zipfile, shutil
arc = ${JSON.stringify(dest)}
tmp = arc + ".tmp"
with zipfile.ZipFile(tmp, "w") as z:
z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip", compress_type=zipfile.ZIP_STORED)
z.writestr("META-INF/container.xml", '<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>')
z.writestr("OEBPS/content.opf", '<?xml version="1.0"?><package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title id="t1">' + ${JSON.stringify(title)} + '</dc:title><dc:identifier id="pub-id">urn:uuid:e2e</dc:identifier></metadata><manifest><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="c1"/></spine></package>')
z.writestr("OEBPS/c1.xhtml", "<html><body>chapter</body></html>")
shutil.move(tmp, arc)`;
writePython(script);
}
function writeCBZ(dest: string): void {
writePython(`import zipfile, shutil
arc = ${JSON.stringify(dest)}
tmp = arc + ".tmp"
with zipfile.ZipFile(tmp, "w") as z:
z.writestr("page_0001.jpg", "not-a-real-jpeg-but-fine")
shutil.move(tmp, arc)`);
}
/** Independent artifact readers — no app code involved. The python is
* written to a temp file (never -c through the shell: paths with
* quotes/newlines are unreadable that way). */
function pythonRead(script: string, absPath: string): string {
const scriptPath = path.join(os.tmpdir(), `e2e-meta-read-${STAMP}.py`);
fs.writeFileSync(scriptPath, script);
return run(`python3 ${JSON.stringify(scriptPath)} ${JSON.stringify(absPath)}`);
}
function artifactTitle(ext: string, absPath: string): string {
switch (ext) {
case "epub":
return pythonRead(`import zipfile, sys, xml.etree.ElementTree as ET
with zipfile.ZipFile(sys.argv[1]) as z:
root = ET.fromstring(z.read('OEBPS/content.opf'))
ns = {'dc': 'http://purl.org/dc/elements/1.1/'}
titles = [t.text or '' for t in root.findall('.//dc:title', ns)]
print(titles[0] if titles else '')`, absPath);
case "cbz":
return pythonRead(`import zipfile, sys, xml.etree.ElementTree as ET
with zipfile.ZipFile(sys.argv[1]) as z:
root = ET.fromstring(z.read('ComicInfo.xml'))
print(root.findtext('Title') or '')`, absPath);
case "pdf":
// one-book dir → the Calibre-style sidecar carries the metadata
return pythonRead(`import os, sys, xml.etree.ElementTree as ET
sidecar = os.path.join(os.path.dirname(sys.argv[1]), 'metadata.opf')
root = ET.parse(sidecar).getroot()
ns = {'dc': 'http://purl.org/dc/elements/1.1/'}
print(root.findtext('.//dc:title', default='', namespaces=ns))`, absPath);
default:
// audio containers: ffprobe the rewritten album tag
return run(
`ffprobe -v error -show_entries format_tags=album -of default=noprint_wrappers=1:nokey=1 ${JSON.stringify(absPath)}`,
);
}
}
async function apiLogin(request: APIRequestContext): Promise<string> {
const res = await request.post("/api/auth/login", {
data: { login: EMAIL, password: PASSWORD },
});
expect(res.status(), "login should succeed").toBe(200);
return (await res.json()).access_token as string;
}
test.describe.configure({ mode: "serial" });
test.describe("Write-to-file per format (real admin UI + artifact verification)", () => {
let page: Page;
test.beforeAll(async ({ browser, request }) => {
fs.mkdirSync(UPLOADS_HOST, { recursive: true });
// Fresh user, promoted to admin in the dev DB (the harness cleanup
// sweeps transient example.com users afterwards).
const reg = await request.post("/api/auth/register", {
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
});
expect([201, 200, 409]).toContain(reg.status());
psql(`UPDATE users SET role='admin' WHERE email='${EMAIL}'`);
token = await apiLogin(request);
expect(token).toBeTruthy();
// Test library with a folder at our upload dir.
const libRes = await request.post("/api/libraries", {
headers: { Authorization: `Bearer ${token}` },
data: { name: `E2E Meta Write ${STAMP}`, type: "ebooks" },
});
expect(libRes.status()).toBe(201);
libraryID = ((await libRes.json()) as { id: string }).id;
const foldRes = await request.post(`/api/libraries/${libraryID}/folders`, {
headers: { Authorization: `Bearer ${token}` },
data: { folder_path: UPLOADS_CONT },
});
expect(foldRes.status()).toBe(201);
// Seed one fixture per format: file on disk + media_items row.
for (const f of FORMATS) {
const rel = `${f.group}/${f.ext}/e2e-fixture.${f.ext}`;
const abs = path.join(UPLOADS_HOST, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
if (f.ext === "epub") writeEPUB(abs, "E2E Original Title");
else if (f.ext === "cbz") writeCBZ(abs);
else if (f.ext === "pdf") fs.writeFileSync(abs, "%PDF-1.4 e2e stub\n");
else {
const codec = f.ext === "mp3" ? "libmp3lame" : f.ext === "flac" ? "flac" : "aac";
run(
`ffmpeg -hide_banner -loglevel error -y -f lavfi -i anullsrc=r=44100:cl=mono -t 2 -c:a ${codec} -metadata title='E2E Original Title' -metadata album='E2E Original Title' ${JSON.stringify(abs)}`,
);
}
f.id = psql(
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ('${libraryID}', 'E2E Original Title', '${rel}', '${f.group}', '${f.mime}', 100, NOW(), NOW(), NOW())
RETURNING id`,
);
// -tAc prints the id row first, then psql's command tag
// ("INSERT 0 1") last; take the first non-empty line.
f.id = f.id.split("\n").find((l) => l.trim())!.trim();
expect(f.id).toMatch(/^[0-9a-f-]{36}$/i);
}
page = await browser.newPage();
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
});
test.afterAll(async () => {
await page?.close();
// Best-effort cleanup: the library delete cascades the items; the
// psql fallback + rm sweep anything the API couldn't.
try {
await fetch(`${BASE}/api/libraries/${libraryID}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
} catch {
/* best effort */
}
fs.rmSync(UPLOADS_HOST, { recursive: true, force: true });
try {
psql(`DELETE FROM libraries WHERE id='${libraryID}'`);
} catch {
/* already gone */
}
});
for (const f of FORMATS) {
test(`writes metadata into ${f.ext} and the file content verifies`, async () => {
const newTitle = `E2E WRITTEN ${f.ext.toUpperCase()}`;
const abs = path.join(UPLOADS_HOST, f.group, f.ext, `e2e-fixture.${f.ext}`);
expect(f.id).toBeTruthy();
// Real admin UI flow: book page → Edit → change title → the
// in-app dialog → Save & write.
await page.goto(`/media/${f.id}`);
await page.click('button:has-text("Edit")');
const modal = page.locator("#metadata-editor-modal");
await expect(modal).toBeVisible();
await page.fill('#metadata-editor-modal input[name="title"]', newTitle);
await page.click('#metadata-editor-modal button:has-text("Write to file")');
const dialog = page.locator("#write-confirm-dialog");
await expect(dialog).toBeVisible();
await dialog.locator('button:has-text("Save & write")').click();
// Wait for the request + inline rescan + page reload to settle.
await page.waitForTimeout(2500);
// Then verify the FILE's metadata — independently of the app
// (read the artifact back with python/ffprobe, not via the app).
let seen = "";
let lastErr = "";
for (let i = 0; i < 20; i++) {
try {
seen = artifactTitle(f.ext, abs);
} catch (e) {
lastErr = String(e);
seen = "";
}
if (seen.includes(newTitle)) break;
await page.waitForTimeout(500);
}
if (!seen.includes(newTitle)) {
console.error(`[${f.ext}] artifact read failed: ${lastErr}`);
}
expect(seen, `${f.ext}: the FILE must carry the written metadata`).toContain(newTitle);
expect(seen, `${f.ext}: the old title must be replaced`).not.toContain("E2E Original Title");
});
}
});
+2
View File
@@ -562,6 +562,7 @@ type UnlinkedBooks struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
}
type UserDashboardPreferences struct {
@@ -597,4 +598,5 @@ type Users struct {
Timezone pgtype.Text `db:"timezone" json:"timezone"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
}
+39 -9
View File
@@ -53,9 +53,15 @@ type Querier interface {
// collections page); same live filter as GetCollectionItems, plus the
// future-hybrid excluded exclusion and an optional library scope.
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
// Count unlinked books for a device
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
// metadata.opf may only be written when NO other non-archived item
// shares the directory; otherwise the sidecar goes to
// <basename>.opf so one book's metadata can never poison dir-mates.
CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error)
// Count unlinked books for a device (ignored rows stay hidden)
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
// Banner count: unresolved queue rows across the caller's own devices.
// Banner count: unresolved, non-ignored queue rows across the caller's own
// devices. Ignored rows must not nag from the dashboard badge.
CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
@@ -274,12 +280,24 @@ type Querier interface {
// Series are grouped PER LIBRARY: the same series name in two libraries
// (novels vs manga adaptations, LN splits) stays two entries. Visible
// filter enforces per-user library visibility at the source.
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
// transaction share a single NOW() timestamp, leaving many groups tied on
// MAX(created_at). Postgres does not guarantee tie order between
// executions, so page boundaries could repeat or drop series. The GROUP BY
// keys make the sort total and deterministic.
//
// Series-page sort/filter contract (extensible registry in
// services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
// it filters in WHERE (the group key — counts stay true); AuthorQuery
// and Status filter via HAVING so COUNT(*) keeps reporting the whole
// series. ViewerUserId scopes the per-user progress aggregates (the rp
// join can't multiply rows: reading_progress is UNIQUE per
// media_item+user). Sort is a BOUND PARAM compared inside the CASE
// cascade — never interpolated — and the GROUP BY tiebreaker stays
// REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
// transaction share a single NOW() timestamp, leaving many groups tied
// on MAX(created_at), and Postgres does not guarantee tie order between
// executions, so page boundaries could repeat or drop series.
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
// Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
// HAVING so the pagination total matches what the page query returns
// under the same filters (Sort is a page-ORDER concern, not a set
// membership one, so it is absent here by design).
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
@@ -456,7 +474,9 @@ type Querier interface {
GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error)
// Get unlinked book by ID
GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error)
// Get unlinked books for a device
// Fetch one row together with its device's owner for authorization.
GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error)
// Get unlinked books for a device (ignored rows stay hidden)
GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
@@ -504,6 +524,9 @@ type Querier interface {
// static collection paths apply.
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
// Soft-dismiss one row (ownership is checked by the caller against the
// device's user_id before calling this).
IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
// Check if book is in collection
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
@@ -530,6 +553,9 @@ type Querier interface {
// archived row's reading data into its active twin instead of waiting for a
// file that will never return.
ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error)
// Page list, ignored bucket: rows the user dismissed, kept for the
// collapsible "Ignored" section so a misclick is recoverable.
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
@@ -553,7 +579,8 @@ type Querier interface {
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
// List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
// Page list: unresolved queue rows for the caller's own devices.
// Page list: unresolved queue rows for the caller's own devices (ignored
// rows are listed by ListIgnoredUnlinkedBooksForUser instead).
ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk.
@@ -637,6 +664,8 @@ type Querier interface {
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
// Undo a soft-dismiss.
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
// Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
@@ -699,6 +728,7 @@ type Querier interface {
// Update universal progress
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
+350 -37
View File
@@ -369,13 +369,39 @@ func (q *Queries) CountCollectionLiveItems(ctx context.Context, arg CountCollect
return column_1, err
}
const CountOtherMediaItemsInDir = `-- name: CountOtherMediaItemsInDir :one
SELECT COUNT(*)::int
FROM media_items
WHERE library_id = $1::uuid
AND id != $2::uuid
AND archived_at IS NULL
AND file_path LIKE ($3::text || '%')
`
type CountOtherMediaItemsInDirParams struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
ID pgtype.UUID `db:"id" json:"id"`
DirPrefix pgtype.Text `db:"dir_prefix" json:"dir_prefix"`
}
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
// metadata.opf may only be written when NO other non-archived item
// shares the directory; otherwise the sidecar goes to
// <basename>.opf so one book's metadata can never poison dir-mates.
func (q *Queries) CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error) {
row := q.db.QueryRow(ctx, CountOtherMediaItemsInDir, arg.LibraryID, arg.ID, arg.DirPrefix)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err
}
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count
FROM unlinked_books
WHERE device_id = $1 AND resolved = false
WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL
`
// Count unlinked books for a device
// Count unlinked books for a device (ignored rows stay hidden)
func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUnlinkedBooks, deviceID)
var count int64
@@ -387,10 +413,11 @@ const CountUnresolvedUnlinkedBooksForUser = `-- name: CountUnresolvedUnlinkedBoo
SELECT COUNT(*) as count
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
`
// Banner count: unresolved queue rows across the caller's own devices.
// Banner count: unresolved, non-ignored queue rows across the caller's own
// devices. Ignored rows must not nag from the dashboard badge.
func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID)
var count int64
@@ -1956,7 +1983,7 @@ const CreateUnlinkedBook = `-- name: CreateUnlinkedBook :one
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
`
type CreateUnlinkedBookParams struct {
@@ -1996,6 +2023,7 @@ func (q *Queries) CreateUnlinkedBook(ctx context.Context, arg CreateUnlinkedBook
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
@@ -5264,21 +5292,71 @@ func (q *Queries) GetDictionaryEntry(ctx context.Context, word string) (Dictiona
const GetDistinctSeries = `-- name: GetDistinctSeries :many
SELECT library_id, series, COUNT(*) as book_count,
MAX(series_count) as total_in_series,
MAX(created_at) as last_entry_at
MAX(created_at) as last_entry_at,
COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count,
MAX(rp.last_read_at) AS last_read_at
FROM media_items
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
LEFT JOIN reading_progress rp
ON rp.media_item_id = media_items.id
AND rp.user_id = $1::uuid
WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
-- Multi-field search (series page): the query matches the series
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
-- a WHERE filter: a group qualifies when ANY of its rows matches,
-- so COUNT(*) keeps reporting the whole series.
AND ($4::text IS NULL OR
series ILIKE '%' || $4::text || '%'
OR author ILIKE '%' || $4::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || $4::text || '%'
))
GROUP BY library_id, series
ORDER BY MAX(created_at) DESC, library_id, series
LIMIT $4 OFFSET $3
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
AND (
$6::text IS NULL
OR CASE $6::text
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
ELSE true
END
)
ORDER BY
CASE WHEN COALESCE($7::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST,
CASE WHEN $7::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST,
CASE WHEN $7::text = 'name_asc' THEN series END ASC NULLS LAST,
CASE WHEN $7::text = 'name_desc' THEN series END DESC NULLS LAST,
CASE WHEN $7::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST,
CASE WHEN $7::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST,
CASE WHEN $7::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST,
CASE WHEN $7::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST,
CASE WHEN $7::text = 'progress_desc'
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
END DESC NULLS LAST,
CASE WHEN $7::text = 'progress_asc'
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
END ASC NULLS LAST,
CASE WHEN $7::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST,
CASE WHEN $7::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST,
library_id, series
LIMIT $9 OFFSET $8
`
type GetDistinctSeriesParams struct {
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
SeriesQuery pgtype.Text `db:"series_query" json:"series_query"`
AuthorQuery pgtype.Text `db:"author_query" json:"author_query"`
Status pgtype.Text `db:"status" json:"status"`
Sort pgtype.Text `db:"sort" json:"sort"`
Offset pgtype.Int4 `db:"offset" json:"offset"`
Limit pgtype.Int4 `db:"limit" json:"limit"`
}
@@ -5289,20 +5367,35 @@ type GetDistinctSeriesRow struct {
BookCount int64 `db:"book_count" json:"book_count"`
TotalInSeries interface{} `db:"total_in_series" json:"total_in_series"`
LastEntryAt interface{} `db:"last_entry_at" json:"last_entry_at"`
FinishedCount int64 `db:"finished_count" json:"finished_count"`
LastReadAt interface{} `db:"last_read_at" json:"last_read_at"`
}
// Series are grouped PER LIBRARY: the same series name in two libraries
// (novels vs manga adaptations, LN splits) stays two entries. Visible
// filter enforces per-user library visibility at the source.
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
// transaction share a single NOW() timestamp, leaving many groups tied on
// MAX(created_at). Postgres does not guarantee tie order between
// executions, so page boundaries could repeat or drop series. The GROUP BY
// keys make the sort total and deterministic.
//
// Series-page sort/filter contract (extensible registry in
// services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
// it filters in WHERE (the group key — counts stay true); AuthorQuery
// and Status filter via HAVING so COUNT(*) keeps reporting the whole
// series. ViewerUserId scopes the per-user progress aggregates (the rp
// join can't multiply rows: reading_progress is UNIQUE per
// media_item+user). Sort is a BOUND PARAM compared inside the CASE
// cascade — never interpolated — and the GROUP BY tiebreaker stays
// REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
// transaction share a single NOW() timestamp, leaving many groups tied
// on MAX(created_at), and Postgres does not guarantee tie order between
// executions, so page boundaries could repeat or drop series.
func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) {
rows, err := q.db.Query(ctx, GetDistinctSeries,
arg.ViewerUserID,
arg.LibraryID,
arg.VisibleLibraryIds,
arg.SeriesQuery,
arg.AuthorQuery,
arg.Status,
arg.Sort,
arg.Offset,
arg.Limit,
)
@@ -5319,6 +5412,8 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
&i.BookCount,
&i.TotalInSeries,
&i.LastEntryAt,
&i.FinishedCount,
&i.LastReadAt,
); err != nil {
return nil, err
}
@@ -5331,22 +5426,62 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
}
const GetDistinctSeriesCount = `-- name: GetDistinctSeriesCount :one
SELECT COUNT(DISTINCT (library_id, series))::int
FROM media_items
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
SELECT COUNT(*)::int FROM (
SELECT library_id, series
FROM media_items
LEFT JOIN reading_progress rp
ON rp.media_item_id = media_items.id
AND rp.user_id = $1::uuid
WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
AND ($4::text IS NULL OR
series ILIKE '%' || $4::text || '%'
OR author ILIKE '%' || $4::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || $4::text || '%'
))
GROUP BY library_id, series
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
AND (
$6::text IS NULL
OR CASE $6::text
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
ELSE true
END
)
) groups
`
type GetDistinctSeriesCountParams struct {
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
SeriesQuery pgtype.Text `db:"series_query" json:"series_query"`
AuthorQuery pgtype.Text `db:"author_query" json:"author_query"`
Status pgtype.Text `db:"status" json:"status"`
}
// Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
// HAVING so the pagination total matches what the page query returns
// under the same filters (Sort is a page-ORDER concern, not a set
// membership one, so it is absent here by design).
func (q *Queries) GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error) {
row := q.db.QueryRow(ctx, GetDistinctSeriesCount, arg.LibraryID, arg.VisibleLibraryIds)
row := q.db.QueryRow(ctx, GetDistinctSeriesCount,
arg.ViewerUserID,
arg.LibraryID,
arg.VisibleLibraryIds,
arg.SeriesQuery,
arg.AuthorQuery,
arg.Status,
)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err
@@ -9809,7 +9944,7 @@ func (q *Queries) GetUniversalProgress(ctx context.Context, arg GetUniversalProg
}
const GetUnlinkedBookByContentId = `-- name: GetUnlinkedBookByContentId :one
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2
`
type GetUnlinkedBookByContentIdParams struct {
@@ -9835,12 +9970,13 @@ func (q *Queries) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinke
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
const GetUnlinkedBookByID = `-- name: GetUnlinkedBookByID :one
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE id = $1
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE id = $1
`
// Get unlinked book by ID
@@ -9861,15 +9997,69 @@ func (q *Queries) GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (Unli
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
const GetUnlinkedBookWithOwner = `-- name: GetUnlinkedBookWithOwner :one
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.user_id, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.id = $1
`
type GetUnlinkedBookWithOwnerRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Fetch one row together with its device's owner for authorization.
func (q *Queries) GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error) {
row := q.db.QueryRow(ctx, GetUnlinkedBookWithOwner, id)
var i GetUnlinkedBookWithOwnerRow
err := row.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.UserID,
&i.DeviceName,
&i.DeviceType,
)
return i, err
}
const GetUnlinkedBooksByDevice = `-- name: GetUnlinkedBooksByDevice :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.device_id = $1 AND ub.resolved = false
WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC
`
@@ -9887,11 +10077,12 @@ type GetUnlinkedBooksByDeviceRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Get unlinked books for a device
// Get unlinked books for a device (ignored rows stay hidden)
func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error) {
rows, err := q.db.Query(ctx, GetUnlinkedBooksByDevice, deviceID)
if err != nil {
@@ -9915,6 +10106,7 @@ func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
@@ -9942,6 +10134,7 @@ SELECT
u.updated_at,
u.timezone,
u.default_audio_speed,
u.keep_screen_awake,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1
@@ -9960,6 +10153,7 @@ type GetUserRow struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
DeviceCount int64 `db:"device_count" json:"device_count"`
}
@@ -9979,6 +10173,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
&i.UpdatedAt,
&i.Timezone,
&i.DefaultAudioSpeed,
&i.KeepScreenAwake,
&i.DeviceCount,
)
return i, err
@@ -10982,6 +11177,17 @@ func (q *Queries) HasRecentConflictResolution(ctx context.Context, arg HasRecent
return exists, err
}
const IgnoreUnlinkedBook = `-- name: IgnoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1
`
// Soft-dismiss one row (ownership is checked by the caller against the
// device's user_id before calling this).
func (q *Queries) IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, IgnoreUnlinkedBook, id)
return err
}
const IncrementSyncQueueAttempts = `-- name: IncrementSyncQueueAttempts :one
UPDATE sync_queue
SET
@@ -11068,7 +11274,7 @@ SET
resolved_at = NOW(),
resolution_method = 'manual_link'
WHERE id = $1
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
`
type LinkUnlinkedBookParams struct {
@@ -11095,6 +11301,7 @@ func (q *Queries) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookPara
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
@@ -11677,6 +11884,72 @@ func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaIt
return items, nil
}
const ListIgnoredUnlinkedBooksForUser = `-- name: ListIgnoredUnlinkedBooksForUser :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
ORDER BY ub.ignored_at DESC
`
type ListIgnoredUnlinkedBooksForUserRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Page list, ignored bucket: rows the user dismissed, kept for the
// collapsible "Ignored" section so a misclick is recoverable.
func (q *Queries) ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error) {
rows, err := q.db.Query(ctx, ListIgnoredUnlinkedBooksForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListIgnoredUnlinkedBooksForUserRow{}
for rows.Next() {
var i ListIgnoredUnlinkedBooksForUserRow
if err := rows.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLibraries = `-- name: ListLibraries :many
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description
FROM libraries l
@@ -13037,7 +13310,7 @@ func (q *Queries) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUI
}
const ListUnresolvedUnlinkedBooks = `-- name: ListUnresolvedUnlinkedBooks :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.resolved = false
@@ -13064,6 +13337,7 @@ type ListUnresolvedUnlinkedBooksRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
@@ -13092,6 +13366,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
@@ -13106,10 +13381,10 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
}
const ListUnresolvedUnlinkedBooksForUser = `-- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC
`
@@ -13127,11 +13402,13 @@ type ListUnresolvedUnlinkedBooksForUserRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Page list: unresolved queue rows for the caller's own devices.
// Page list: unresolved queue rows for the caller's own devices (ignored
// rows are listed by ListIgnoredUnlinkedBooksForUser instead).
func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) {
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID)
if err != nil {
@@ -13155,6 +13432,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
@@ -13811,7 +14089,7 @@ SET
resolved_at = NOW(),
resolution_method = $3
WHERE id = $1
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
`
type ResolveUnlinkedBookParams struct {
@@ -13838,6 +14116,7 @@ func (q *Queries) ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBo
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
@@ -14460,11 +14739,17 @@ SELECT
mi.opf_uuid,
mi.hash_confidence,
l.name as library_name,
lt.name as library_type_name
lt.name as library_type_name,
-- Viewer's own progress (search-result badges): NULL when the
-- requester never opened the book. Reuses the SAME requester param
-- the visibility join binds; UNIQUE(media_item, user) keeps the
-- join row-neutral.
rp.percentage as viewer_percentage
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
@@ -14730,6 +15015,7 @@ type SearchMediaItemsUnifiedRow struct {
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
LibraryName string `db:"library_name" json:"library_name"`
LibraryTypeName string `db:"library_type_name" json:"library_type_name"`
ViewerPercentage pgtype.Float8 `db:"viewer_percentage" json:"viewer_percentage"`
}
func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error) {
@@ -14825,6 +15111,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
&i.HashConfidence,
&i.LibraryName,
&i.LibraryTypeName,
&i.ViewerPercentage,
); err != nil {
return nil, err
}
@@ -15243,6 +15530,16 @@ func (q *Queries) TombstoneSystemCollection(ctx context.Context, id pgtype.UUID)
return err
}
const UnignoreUnlinkedBook = `-- name: UnignoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1
`
// Undo a soft-dismiss.
func (q *Queries) UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, UnignoreUnlinkedBook, id)
return err
}
const UpdateCollection = `-- name: UpdateCollection :one
UPDATE collections
SET
@@ -17236,9 +17533,23 @@ func (q *Queries) UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUse
return err
}
const UpdateUserKeepScreenAwake = `-- name: UpdateUserKeepScreenAwake :exec
UPDATE users SET keep_screen_awake = $2, updated_at = NOW() WHERE id = $1
`
type UpdateUserKeepScreenAwakeParams struct {
ID pgtype.UUID `db:"id" json:"id"`
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
}
func (q *Queries) UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error {
_, err := q.db.Exec(ctx, UpdateUserKeepScreenAwake, arg.ID, arg.KeepScreenAwake)
return err
}
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed, keep_screen_awake
`
type UpdateUserMaxDevicesParams struct {
@@ -17263,6 +17574,7 @@ func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDev
&i.Timezone,
&i.UpdatedAt,
&i.DefaultAudioSpeed,
&i.KeepScreenAwake,
)
return i, err
}
@@ -17631,7 +17943,7 @@ SET file_path = EXCLUDED.file_path,
author = EXCLUDED.author,
confidence_score = EXCLUDED.confidence_score,
last_seen_at = NOW()
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
`
type UpsertUnlinkedBookParams struct {
@@ -17669,6 +17981,7 @@ func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBook
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
+152 -21
View File
@@ -29,6 +29,7 @@ SELECT
u.updated_at,
u.timezone,
u.default_audio_speed,
u.keep_screen_awake,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1;
@@ -515,6 +516,9 @@ UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserDefaultAudioSpeed :exec
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserKeepScreenAwake :exec
UPDATE users SET keep_screen_awake = $2, updated_at = NOW() WHERE id = $1;
-- Per-book playback speed preference (player plan §8).
-- name: GetAudioPlaybackSpeed :one
@@ -730,11 +734,17 @@ SELECT
mi.opf_uuid,
mi.hash_confidence,
l.name as library_name,
lt.name as library_type_name
lt.name as library_type_name,
-- Viewer's own progress (search-result badges): NULL when the
-- requester never opened the book. Reuses the SAME requester param
-- the visibility join binds; UNIQUE(media_item, user) keeps the
-- join row-neutral.
rp.percentage as viewer_percentage
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
@@ -3697,12 +3707,12 @@ INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, con
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- Get unlinked books for a device
-- Get unlinked books for a device (ignored rows stay hidden)
-- name: GetUnlinkedBooksByDevice :many
SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.device_id = $1 AND ub.resolved = false
WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC;
-- Get unlinked book by ContentId
@@ -3720,11 +3730,11 @@ SET
WHERE id = $1
RETURNING *;
-- Count unlinked books for a device
-- Count unlinked books for a device (ignored rows stay hidden)
-- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count
FROM unlinked_books
WHERE device_id = $1 AND resolved = false;
WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL;
-- Link unlinked book to media item
-- name: LinkUnlinkedBook :one
@@ -3768,21 +3778,48 @@ SET file_path = EXCLUDED.file_path,
last_seen_at = NOW()
RETURNING *;
-- Banner count: unresolved queue rows across the caller's own devices.
-- Banner count: unresolved, non-ignored queue rows across the caller's own
-- devices. Ignored rows must not nag from the dashboard badge.
-- name: CountUnresolvedUnlinkedBooksForUser :one
SELECT COUNT(*) as count
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false;
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL;
-- Page list: unresolved queue rows for the caller's own devices.
-- Page list: unresolved queue rows for the caller's own devices (ignored
-- rows are listed by ListIgnoredUnlinkedBooksForUser instead).
-- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC;
-- Page list, ignored bucket: rows the user dismissed, kept for the
-- collapsible "Ignored" section so a misclick is recoverable.
-- name: ListIgnoredUnlinkedBooksForUser :many
SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
ORDER BY ub.ignored_at DESC;
-- Soft-dismiss one row (ownership is checked by the caller against the
-- device's user_id before calling this).
-- name: IgnoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1;
-- Undo a soft-dismiss.
-- name: UnignoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1;
-- Fetch one row together with its device's owner for authorization.
-- name: GetUnlinkedBookWithOwner :one
SELECT ub.*, d.user_id, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.id = $1;
-- ============================================
-- CAROUSEL-STYLE DASHBOARD
-- ============================================
@@ -4040,32 +4077,126 @@ ORDER BY mi.created_at DESC;
-- Series are grouped PER LIBRARY: the same series name in two libraries
-- (novels vs manga adaptations, LN splits) stays two entries. Visible
-- filter enforces per-user library visibility at the source.
--
-- Series-page sort/filter contract (extensible registry in
-- services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
-- it filters in WHERE (the group key — counts stay true); AuthorQuery
-- and Status filter via HAVING so COUNT(*) keeps reporting the whole
-- series. ViewerUserId scopes the per-user progress aggregates (the rp
-- join can't multiply rows: reading_progress is UNIQUE per
-- media_item+user). Sort is a BOUND PARAM compared inside the CASE
-- cascade — never interpolated — and the GROUP BY tiebreaker stays
-- REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
-- transaction share a single NOW() timestamp, leaving many groups tied
-- on MAX(created_at), and Postgres does not guarantee tie order between
-- executions, so page boundaries could repeat or drop series.
SELECT library_id, series, COUNT(*) as book_count,
MAX(series_count) as total_in_series,
MAX(created_at) as last_entry_at
MAX(created_at) as last_entry_at,
COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count,
MAX(rp.last_read_at) AS last_read_at
FROM media_items
LEFT JOIN reading_progress rp
ON rp.media_item_id = media_items.id
AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
-- Multi-field search (series page): the query matches the series
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
-- a WHERE filter: a group qualifies when ANY of its rows matches,
-- so COUNT(*) keeps reporting the whole series.
AND (sqlc.narg('series_query')::text IS NULL OR
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR author ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || sqlc.narg('series_query')::text || '%'
))
GROUP BY library_id, series
-- Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
-- transaction share a single NOW() timestamp, leaving many groups tied on
-- MAX(created_at). Postgres does not guarantee tie order between
-- executions, so page boundaries could repeat or drop series. The GROUP BY
-- keys make the sort total and deterministic.
ORDER BY MAX(created_at) DESC, library_id, series
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
AND (
sqlc.narg('status')::text IS NULL
OR CASE sqlc.narg('status')::text
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
ELSE true
END
)
ORDER BY
CASE WHEN COALESCE(sqlc.narg('sort')::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'name_asc' THEN series END ASC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'name_desc' THEN series END DESC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'progress_desc'
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
END DESC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'progress_asc'
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
END ASC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST,
CASE WHEN sqlc.narg('sort')::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST,
library_id, series
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: GetDistinctSeriesCount :one
SELECT COUNT(DISTINCT (library_id, series))::int
-- Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
-- HAVING so the pagination total matches what the page query returns
-- under the same filters (Sort is a page-ORDER concern, not a set
-- membership one, so it is absent here by design).
SELECT COUNT(*)::int FROM (
SELECT library_id, series
FROM media_items
LEFT JOIN reading_progress rp
ON rp.media_item_id = media_items.id
AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
AND (sqlc.narg('series_query')::text IS NULL OR
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR author ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || sqlc.narg('series_query')::text || '%'
))
GROUP BY library_id, series
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
AND (
sqlc.narg('status')::text IS NULL
OR CASE sqlc.narg('status')::text
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
ELSE true
END
)
) groups;
-- name: CountOtherMediaItemsInDir :one
-- Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
-- metadata.opf may only be written when NO other non-archived item
-- shares the directory; otherwise the sidecar goes to
-- <basename>.opf so one book's metadata can never poison dir-mates.
SELECT COUNT(*)::int
FROM media_items
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
WHERE library_id = sqlc.narg('library_id')::uuid
AND id != sqlc.narg('id')::uuid
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != '';
AND file_path LIKE (sqlc.narg('dir_prefix')::text || '%');
-- name: GetSeriesCovers :many
SELECT cover_image_path, library_id
+4 -1
View File
@@ -147,6 +147,9 @@ var Schemas = map[string]Schema{
"POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": {
Response: json.RawMessage(`{"success": false, "message": ""}`),
},
"POST /api/libraries/:id/issues/:issueId/retry": {
Response: json.RawMessage(`{"success": false, "message": ""}`),
},
"DELETE /api/libraries/:id/issues/:issueId": {
Response: json.RawMessage(`{"success": false, "message": ""}`),
},
@@ -705,7 +708,7 @@ var Schemas = map[string]Schema{
},
"PUT /api/auth/profile": {
// Shared with admin variant (PUT /auth/profile/:id).
Request: handlers.AdminUpdateUserRequest{},
Request: handlers.ProfileUpdateRequest{},
Response: json.RawMessage(`{"message": ""}`),
},
"DELETE /api/auth/profile": {
+45 -32
View File
@@ -10,6 +10,7 @@ import (
"context"
"errors"
"fmt"
"html"
"html/template"
"math"
"net/http"
@@ -122,21 +123,11 @@ type UserProfile struct {
Theme string `json:"theme,omitempty"`
// User-level default audiobook playback speed (plan §8); 0 omits.
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
// Whether the web reader/player should hold a Screen Wake Lock.
KeepScreenAwake bool `json:"keep_screen_awake"`
}
type UpdateProfileRequest struct {
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
// Per the SPEEDS list bounds; the profile select always sends it,
// JSON clients omit it (0 = untouched).
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
}
type AdminUpdateUserRequest struct {
type ProfileUpdateRequest struct {
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
@@ -144,7 +135,11 @@ type AdminUpdateUserRequest struct {
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
// Same semantics as UpdateProfileRequest.DefaultAudioSpeed.
// "true"/"false" — the profile dropdown always sends it; JSON clients
// that omit it decode as "" and leave the column untouched.
KeepScreenAwake string `json:"keep_screen_awake,omitempty" form:"keep_screen_awake" validate:"omitempty,oneof=true false"`
// Per the SPEEDS list bounds; the profile select always sends it,
// JSON clients omit it (0 = untouched).
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
}
@@ -341,13 +336,9 @@ func (h *AuthHandler) Register(c *echo.Context) error {
}
if c.Request().Header.Get("HX-Request") == "true" {
html := fmt.Sprintf(`<div class="text-green-500">Registration successful! Redirecting...</div>
<script>
localStorage.setItem('token', '%s');
localStorage.setItem('refreshToken', '%s');
localStorage.setItem('user', JSON.stringify(%s));
window.location.href = '/dashboard';
</script>`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username))
// CSP: see the login handler — data attributes, not inline scripts.
html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="/dashboard">Registration successful! Redirecting...</div>`,
accessToken)
return c.HTML(http.StatusCreated, html)
}
@@ -374,6 +365,10 @@ window.location.href = '/dashboard';
}
// Login handles POST /api/auth/login
// invalidCredentialsHTML is the htmx error partial for failed logins;
// web/src/auth_forms.ts swaps 4xx/5xx HTML responses into the form target.
const invalidCredentialsHTML = `<div class="text-red-500">Invalid email/username or password.</div>`
func (h *AuthHandler) Login(c *echo.Context) error {
login := c.FormValue("login")
password := c.FormValue("password")
@@ -430,7 +425,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
}
if c.Request().Header.Get("HX-Request") == "true" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return c.HTML(http.StatusUnauthorized, invalidCredentialsHTML)
}
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
}
@@ -446,7 +441,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
}
if c.Request().Header.Get("HX-Request") == "true" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return c.HTML(http.StatusUnauthorized, invalidCredentialsHTML)
}
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
}
@@ -487,17 +482,18 @@ func (h *AuthHandler) Login(c *echo.Context) error {
}
if c.Request().Header.Get("HX-Request") == "true" {
// CSP: hash-mode script-src refuses inline scripts, so the htmx
// partial carries data attributes instead; web/src/auth_forms.ts
// stashes the token and navigates on htmx:afterSwap. The redirect
// target is path-only (open-redirect hygiene) and the old
// 'user'/'refreshToken' localStorage writes are gone — nothing
// ever read them.
redirect := c.FormValue("redirect")
if redirect == "" {
if redirect == "" || !strings.HasPrefix(redirect, "/") {
redirect = "/dashboard"
}
html := fmt.Sprintf(`<div class="text-green-500">Login successful! Redirecting...</div>
<script>
localStorage.setItem('token', '%s');
localStorage.setItem('refreshToken', '%s');
localStorage.setItem('user', JSON.stringify(%s));
window.location.href = '%s';
</script>`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s","first_name":"%s","last_name":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username, user.FirstName.String, user.LastName.String), redirect)
html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="%s">Login successful! Redirecting...</div>`,
accessToken, html.EscapeString(redirect))
return c.HTML(http.StatusOK, html)
}
@@ -553,6 +549,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
Role: user.Role,
Theme: user.Theme.String,
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
KeepScreenAwake: user.KeepScreenAwake,
})
}
@@ -581,7 +578,7 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
targetUserUUID = currentUser.ID
}
var req AdminUpdateUserRequest
var req ProfileUpdateRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
@@ -717,6 +714,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
}
}
// Keep-screen-awake preference. The form dropdown always sends it
// ("true"/"false"); JSON clients that omit it decode as "" and leave
// the column untouched.
if req.KeepScreenAwake != "" {
if req.KeepScreenAwake != "true" && req.KeepScreenAwake != "false" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "keep_screen_awake must be 'true' or 'false'"})
}
err := h.db.UpdateUserKeepScreenAwake(c.Request().Context(), database.UpdateUserKeepScreenAwakeParams{
ID: targetUserUUID,
KeepScreenAwake: req.KeepScreenAwake == "true",
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Browser path (htmx modal + profile page): refresh so every rendered
// value (timezone select, table rows, header) reflects the save and the
// modal closes. A bare JSON body swapped into #profile-result read as
+39 -15
View File
@@ -13,7 +13,7 @@ import (
// BookInfo is THE canonical wire+template card shape: every surface that
// lists a book card as JSON (collections detail, series, preview,
// dashboard sections, search) or renders a templ card (BookCard in
// dashboard.templ, BooksGrid, BrowseDetail) carries exactly these seven
// dashboard.templ, BooksGrid, BrowseDetail) carries exactly these nine
// keys and no others — pinned by docs/contract_pins.md.
//
// H2 doctrine: this struct is constructed in EXACTLY ONE place —
@@ -34,6 +34,15 @@ type BookInfo struct {
// comic vs manga rides the LIBRARY type (format_group only knows
// comic_archive — manga_type is unreliable across sources).
Badge string `json:"badge"`
// Viewer-scoped extras (2026-10-27 search-progress batch): nil
// percentage = the requesting user never opened the book, so cards
// render no progress bar and no completed badge. Adapters that
// don't load viewer progress leave it nil; the search-unified
// adapter is the only populated feed today. LibraryName rides the
// same deal (unified rows carry it as a column; other feeds leave
// it empty rather than pay a join).
ViewerPercentage *float64 `json:"viewer_percentage"`
LibraryName string `json:"library_name"`
}
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
@@ -79,12 +88,14 @@ func MediaBadgeKind(formatGroup, libraryTypeName string) string {
// adapters below normalize the sqlc row shapes into cardSource.
func buildBookCard(src cardSource) BookInfo {
return BookInfo{
MediaItemID: src.ID.String(),
Title: src.Title,
Author: src.Author,
CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
FormatGroup: src.FormatGroup,
Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
MediaItemID: src.ID.String(),
Title: src.Title,
Author: src.Author,
CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
FormatGroup: src.FormatGroup,
Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
ViewerPercentage: src.ViewerProgress,
LibraryName: src.LibraryName,
}
}
@@ -102,6 +113,10 @@ type cardSource struct {
CoverPath pgtype.Text
FormatGroup string
LibraryType string
// Viewer-scoped extras: nil progress = no data loaded for the
// requesting user (see BookInfo.ViewerPercentage).
ViewerProgress *float64
LibraryName string
}
// BookCardsFromMediaItems builds cards from full media rows
@@ -167,18 +182,27 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
// BookCardsFromUnified builds cards from search-unified rows. Those
// rows carry the library type name as a SQL column, so this adapter
// needs no LibraryTypeMap query at all.
// needs no LibraryTypeMap query at all — and the viewer's own
// progress (the 2026-10-27 search-progress batch), which no other
// feed loads.
func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo {
cards := make([]BookInfo, 0, len(rows))
for _, r := range rows {
var viewer *float64
if r.ViewerPercentage.Valid {
pct := r.ViewerPercentage.Float64
viewer = &pct
}
cards = append(cards, buildBookCard(cardSource{
ID: uuid.UUID(r.ID.Bytes),
Title: r.Title,
Author: utils.PgTextString(r.Author),
LibraryID: r.LibraryID,
CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup,
LibraryType: r.LibraryTypeName,
ID: uuid.UUID(r.ID.Bytes),
Title: r.Title,
Author: utils.PgTextString(r.Author),
LibraryID: r.LibraryID,
CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup,
LibraryType: r.LibraryTypeName,
ViewerProgress: viewer,
LibraryName: r.LibraryName,
}))
}
return cards
+2 -1
View File
@@ -496,7 +496,8 @@ func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
}
if c.Request().Header.Get("HX-Request") == "true" {
html := `<div class="text-green-500">Collection deleted! Redirecting...</div><script>window.location.href = '/collections';</script>`
// CSP: no inline scripts in partials — auth_forms.ts navigates.
html := `<div class="text-green-500" data-redirect="/collections">Collection deleted! Redirecting...</div>`
return c.HTML(http.StatusOK, html)
}
+4 -1
View File
@@ -499,7 +499,10 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
// Handle HTMX requests - return HTML with page reload script
if c.Request().Header.Get("HX-Request") == "true" {
html := `<div class="text-green-500">Token regenerated! Reloading ... </div><script>window.location.reload();`
// CSP: no inline scripts in partials; HX-Refresh makes htmx
// reload the page natively (the old script tag was also unclosed).
c.Response().Header().Set("HX-Refresh", "true")
html := `<div class="text-green-500">Token regenerated! Reloading ... </div>`
return c.HTML(http.StatusOK, html)
}
return c.JSON(http.StatusOK, map[string]interface{}{
+51
View File
@@ -1801,6 +1801,57 @@ func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
return c.JSON(http.StatusOK, item)
}
// WriteMetadataToFile handles POST /api/media-items/:id/write-metadata
// (admin only). Persists the item's CURRENT database metadata into the
// artifact on disk — embedded OPF for EPUBs, a Calibre-style sidecar
// for formats that can't carry metadata (2026-10-27 feature) — then
// re-extracts from the file so the scan-derived columns agree with
// what was written. metadata_overrides are NOT reset: after the write,
// file and DB agree, and the overrides keep stale sidecars from
// clobbering future rescans.
func (mh *MediaHandler) WriteMetadataToFile(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
writer := services.NewMetadataWriter(mh.db)
result, err := writer.WriteMetadataToFile(c.Request().Context(), mediaUUID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Re-extract so the scan pipeline sees the file we just wrote. A
// rescan failure doesn't undo the write — it surfaces separately.
rescanStatus := "ok"
scanner := services.NewMediaScanner(mh.db)
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, false); err != nil {
rescanStatus = "error: " + err.Error()
}
defer scanner.Close()
return c.JSON(http.StatusOK, map[string]interface{}{
"write": result,
"rescan": rescanStatus,
"metadata_target": string(result.Target),
"fields_written": result.FieldsWritten,
})
}
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
+73
View File
@@ -3,6 +3,8 @@ package handlers
import (
"bookhoard/internal/database"
"context"
"errors"
"fmt"
"net/http"
"time"
@@ -218,6 +220,77 @@ func (h *ProcessingIssuesHandler) gateIssue(c *echo.Context, issueID uuid.UUID)
return nil
}
// RetryProcessingIssue re-runs the work that produced an open issue
// through the individual scanner — NOT a library-wide rescan:
//
// - item-backed issues rescan their media item (the book-detail
// RescanMediaItem mechanism: self-resolving folders, multi-file
// audiobook routing, write-after-success guards);
// - row-less import_failed issues re-import the single file through
// the watcher's standalone processMediaFile path.
//
// Success closes the issue (explicitly for the item shape; the create
// path's self-heal plus the explicit path resolve below for row-less).
// Failure returns the REAL error so the UI can say why it still fails.
func (h *ProcessingIssuesHandler) RetryProcessingIssue(c *echo.Context) error {
issueID, err := uuid.Parse(c.Param("issueId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"})
}
issue, err := h.db.GetProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Issue not found"})
}
if !issue.LibraryID.Valid {
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has no library attribution"})
}
if err := h.gateIssue(c, issueID); err != nil {
return err
}
if issue.Resolved.Valid && issue.Resolved.Bool {
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue is already resolved"})
}
scanner := services.NewMediaScanner(h.db)
defer scanner.Close()
if issue.MediaItemID.Valid {
if err := scanner.RetryMediaItemIssue(c.Request().Context(), issue.MediaItemID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{
"success": true,
"message": "Item rescanned; issue resolved",
})
}
// Row-less import failure: the file never became a row.
if !issue.FilePath.Valid || issue.FilePath.String == "" {
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has neither a media item nor a file path"})
}
relativePath := issue.FilePath.String
if err := scanner.RetryFailedImport(c.Request().Context(), issue.LibraryID, relativePath); err != nil {
if errors.Is(err, services.ErrRetryFileMissing) {
return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// The import chain's self-heal closes path-keyed issues on create;
// an already-imported duplicate (a previous partial retry) skips
// that path, so close explicitly here too. Idempotent.
if _, err := h.db.ResolveOpenProcessingIssuesForPath(c.Request().Context(), database.ResolveOpenProcessingIssuesForPathParams{
LibraryID: issue.LibraryID,
FilePath: pgtype.Text{String: relativePath, Valid: true},
}); err != nil {
fmt.Printf("Warning: retry imported %s but failed to close its issues: %v\n", relativePath, err)
}
return c.JSON(http.StatusOK, map[string]any{
"success": true,
"message": "File imported; issue resolved",
})
}
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
return h.db.GetProcessingIssueStats(ctx, libraryID)
+28 -4
View File
@@ -27,7 +27,12 @@ func (h *Handler) StartBackgroundTasks() {
type ScanLibraryRequest struct {
FolderPaths []string `json:"folder_paths,omitempty"`
LibraryID string `json:"library_id,omitempty"`
Force bool `json:"force,omitempty"`
// FolderPath (singular) scopes a library scan to ONE of the library's
// registered folders (the LibraryPanel per-folder Scan buttons). It
// must be a registered folder — validated below, never trusted. The
// raw /api/scanner/scan route ignores it (no library attribution).
FolderPath string `json:"folder_path,omitempty"`
Force bool `json:"force,omitempty"`
}
// ScanLibrary handles POST /api/scanner/scan (now runs in background)
@@ -68,9 +73,28 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"})
}
// Extract folder paths
for _, folder := range libraryFolders {
folderPaths = append(folderPaths, folder.FolderPath)
if req.FolderPath != "" {
// Per-folder scope (LibraryPanel per-folder Scan buttons):
// scan exactly one registered folder's subtree. The archive
// reconciliation pass skips partially-covered libraries
// (processScanJob → ScanFolders), so the other folders'
// items cannot be marked missing by this run.
registered := false
for _, folder := range libraryFolders {
if folder.FolderPath == req.FolderPath {
registered = true
break
}
}
if !registered {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "folder_path is not one of this library's registered folders"})
}
folderPaths = []string{req.FolderPath}
} else {
// Extract folder paths
for _, folder := range libraryFolders {
folderPaths = append(folderPaths, folder.FolderPath)
}
}
if len(folderPaths) == 0 {
+20 -3
View File
@@ -64,7 +64,20 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset)
// Sort/filter params (same contract as the SSR /series page):
// unknown values resolve to defaults inside the service.
opts := services.SeriesPageOptions{
Sort: c.QueryParam("sort"),
Dir: c.QueryParam("dir"),
Query: c.QueryParam("q"),
Author: c.QueryParam("author"),
Status: c.QueryParam("status"),
}
if u, ok := c.Get("user").(database.Users); ok {
opts.ViewerUserID = u.ID
}
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset, opts)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
}
@@ -76,6 +89,8 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
TotalInSeries int `json:"total_in_series"`
CoverPaths []string `json:"cover_paths"`
LastEntryAt string `json:"last_entry_at"`
ReadCount int64 `json:"read_count"`
LastReadAt string `json:"last_read_at"`
}
response := make([]SeriesResponse, 0, len(seriesList))
@@ -87,6 +102,8 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
TotalInSeries: s.TotalInSeries,
CoverPaths: s.CoverPaths,
LastEntryAt: s.LastEntryAt,
ReadCount: s.ReadCount,
LastReadAt: s.LastReadAt,
})
}
@@ -150,7 +167,7 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
})
}
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts services.SeriesPageOptions) ([]services.SeriesInfo, int, error) {
svc := services.NewSeriesService(db)
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset)
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset, opts)
}
+10 -4
View File
@@ -489,12 +489,18 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
defaultTimezone = tz
}
// Render success message with updated form
// Render success message with updated form. NOTE: this Go-source
// copy is the htmx-swapped twin of the templ form in
// templates/admin_settings.templ (handlers cannot import templates:
// templates/utils.go imports handlers types — cycle). It MUST keep
// the twin's functional attributes — id, hx-*, data-settings-save,
// hx-disabled-elt — or save feedback silently dies after the first
// swap. e2e/settings-feedback.spec.ts saves twice to pin this.
return c.HTML(http.StatusOK, fmt.Sprintf(`
<div class="mb-4 p-4 rounded-lg" style="background-color: var(--bg-secondary); border: 1px solid var(--accent);">
<p style="color: var(--text-success);">✅ Settings saved successfully!</p>
</div>
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
<form id="settings-form" data-settings-save="settings" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Base URL</h3>
@@ -513,7 +519,7 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
</div>
<div class="mt-6 flex justify-end">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium" hx-disabled-elt="this">
Save Settings
</button>
</div>
@@ -551,7 +557,7 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
</div>
<div class="mt-6 flex justify-end">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium" hx-disabled-elt="this">
Save Settings
</button>
</div>
@@ -0,0 +1,69 @@
package middleware_test
import (
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// Go-source CSP tripwire.
//
// The templ scan lives in templates/csp_source_test.go, but htmx partials
// can also be emitted from Go handler strings — auth.go used to answer a
// successful login with an inline <script>window.location…</script> that
// hash-mode CSP silently refused. This test scans the non-test Go sources
// for the same forbidden patterns: inline script tags and native on*=
// handler attributes in emitted HTML. Partials that need to navigate or
// stash state use data attributes + web/src/auth_forms.ts, or htmx's
// HX-Redirect/HX-Refresh headers.
var goScriptTagRe = regexp.MustCompile(`<script`)
var goHandlerAttrRe = regexp.MustCompile(
`on(click|error|submit|load|change|input|focus|blur|mouseover|keydown|keyup)\s*=\s*"`)
func TestGoSourcesAreCSPSafe(t *testing.T) {
root := "../.."
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case "node_modules", "static", ".git", "testdata", "docs":
return filepath.SkipDir
}
return nil
}
name := d.Name()
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
return nil
}
// Generated templ codegen is out of scope: its source of truth is
// the .templ file (scanned by templates/csp_source_test.go), and
// the codegen legitimately emits external <script src=…> tags.
if strings.HasSuffix(name, "_templ.go") {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
src := string(raw)
for _, m := range goScriptTagRe.FindAllStringIndex(src, -1) {
t.Errorf("%s:%d: literal <script> in Go source — hash-mode CSP refuses inline scripts in emitted partials; use data attributes + web/src/auth_forms.ts or HX-Redirect/HX-Refresh",
path, 1+strings.Count(src[:m[0]], "\n"))
}
for _, m := range goHandlerAttrRe.FindAllStringIndex(src, -1) {
t.Errorf("%s:%d: native on*= handler attribute in Go source — CSP hash-mode refuses it; use Alpine bindings or delegated listeners",
path, 1+strings.Count(src[:m[0]], "\n"))
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
+25 -3
View File
@@ -1,6 +1,8 @@
package middleware
import (
"strings"
"github.com/labstack/echo/v5"
)
@@ -9,7 +11,14 @@ import (
// the rendered component by security_headers_test.go.
const ScriptHashThemeFlashGuard = "'sha256-JYIVpFyKdLUtWizMha0v69f6yHds+GbJRBWjvpuECBY='"
// SecurityHeadersMiddleware adds security headers to all responses
// SecurityHeadersMiddleware adds security headers to all responses.
// It also forces HTML pages to revalidate (Cache-Control: no-cache):
// SSR pages are dynamic, and heuristic browser caching of a PAGE let a
// stale page reference a stale main.js stamp long after the bundle was
// fixed — the "search dropdown still broken" report was exactly that.
// /api, /static and /uploads keep their own (or absent) caching: the
// stamped bundles are content-frozen by URL, media files are immutable
// by path.
func SecurityHeadersMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
@@ -24,8 +33,8 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
//
// The inline script allow-list: the theme flash guard is the
// ONLY inline script in the app, and its byte-stable content is
// hashed into script-src (sha256 of the bytes between
// <script> and </script> as rendered by
// hashed into script-src (sha256 of the bytes between the
// script tags as rendered by
// templates.ThemeFlashGuard()). Editing the guard requires
// recomputing the hash — enforced by
// internal/middleware/security_headers_test.go, which renders
@@ -39,6 +48,19 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
// Pages must always revalidate (see doc comment). The check
// runs BEFORE next(): once a templ handler writes the body,
// response headers are already on the wire.
switch p := c.Request().URL.Path; {
case strings.HasPrefix(p, "/api/"),
strings.HasPrefix(p, "/static/"),
strings.HasPrefix(p, "/uploads/"),
p == "/health":
// Not a page: keep native caching behavior.
default:
c.Response().Header().Set("Cache-Control", "no-cache")
}
return next(c)
}
}
@@ -4,12 +4,16 @@ import (
"context"
"crypto/sha256"
"encoding/base64"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"bookhoard/internal/middleware"
"bookhoard/templates"
"github.com/labstack/echo/v5"
)
// TestThemeFlashGuardHashMatchesCSP pins the CSP script hash to the theme
@@ -49,3 +53,38 @@ middleware.ScriptHashThemeFlashGuard and the CSP header string.`,
t.Error("script-src must not re-introduce 'unsafe-inline' alongside the hash")
}
}
// HTML pages must always revalidate (Cache-Control: no-cache): a
// heuristically cached PAGE referenced a stale main.js stamp and kept
// the old bundle running long after a fix deployed (the 2026-10-27
// "search dropdown still broken" report). Static/API/media routes keep
// their native caching — stamped bundles are content-frozen by URL.
func TestHTMLPagesRevalidate(t *testing.T) {
mw := middleware.SecurityHeadersMiddleware()
render := func(path string) string {
e := echo.New()
req, _ := http.NewRequest("GET", path, nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := mw(func(c *echo.Context) error {
return c.HTML(http.StatusOK, "<html></html>")
})
if err := handler(c); err != nil {
t.Fatalf("handler error: %v", err)
}
return rec.Header().Get("Cache-Control")
}
if got := render("/bookshelf"); got != "no-cache" {
t.Errorf("/bookshelf: Cache-Control = %q, want no-cache", got)
}
if got := render("/series"); got != "no-cache" {
t.Errorf("/series: Cache-Control = %q, want no-cache", got)
}
for _, p := range []string{"/static/main.js", "/api/media-items", "/uploads/library-x/book.epub", "/health"} {
if got := render(p); got == "no-cache" {
t.Errorf("%s: must keep native caching, got no-cache", p)
}
}
}
+221 -3
View File
@@ -149,6 +149,36 @@ func registerFrontendRoutes(cfg *Config) {
}
offset := (page - 1) * perSeriesPage
// Series-page state (URL is the source of truth — plain GET form,
// no htmx on this page). Unknown values resolve to defaults in the
// service's registries, so hand-edited URLs stay safe. The URL
// carries the BASE sort key + direction; opts.Sort gets the
// composed registry key (full keys win on re-resolve). With no
// sort param at all, state stays empty so default URLs remain
// clean (the toolbar treats empty as recent/desc).
rawSort := c.QueryParam("sort")
sortKey, sortDir := services.ResolveSeriesSort(rawSort, c.QueryParam("dir"))
state := templates.SeriesPageState{
Sort: services.SeriesSortBase(sortKey),
Dir: sortDir,
Query: c.QueryParam("q"),
Status: c.QueryParam("status"),
}
if rawSort == "" {
state.Sort = ""
state.Dir = ""
}
opts := services.SeriesPageOptions{
Sort: sortKey,
Dir: sortDir,
Query: state.Query,
Author: c.QueryParam("author"), // API/deep-link back-compat; the toolbar's multi-field q covers authors
Status: state.Status,
}
if viewerID, vidErr := uuid.Parse(user.ID); vidErr == nil {
opts.ViewerUserID = uuidToPGType(viewerID)
}
var seriesCards []templates.SeriesCardData
totalPages := 1
@@ -162,7 +192,7 @@ func registerFrontendRoutes(cfg *Config) {
} else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] {
errorMsg = "Library not accessible"
} else {
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset)
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset, opts)
if err != nil {
log.Printf("GetSeriesCardsData failed: %v", err)
errorMsg = "Error loading series"
@@ -183,6 +213,7 @@ func registerFrontendRoutes(cfg *Config) {
BookCount: s.BookCount,
TotalInSeries: s.TotalInSeries,
CoverPaths: covers,
ReadCount: s.ReadCount,
})
}
}
@@ -194,7 +225,7 @@ func registerFrontendRoutes(cfg *Config) {
}
var buf bytes.Buffer
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, errorMsg).Render(c.Request().Context(), &buf)
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, state, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -767,6 +798,8 @@ func registerFrontendRoutes(cfg *Config) {
// resolution rules as the dashboard page (family-lens safe).
// Unlinked-books page: the identification queue for device files the
// sync could not match (personal data — the caller's own devices only).
// Unlinked-book queue page: unresolved rows plus the ignored bucket
// (soft-dismissed rows, kept for the collapsible recovery section).
frontendProtected.GET("/unlinked-books", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
@@ -781,6 +814,11 @@ func registerFrontendRoutes(cfg *Config) {
log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr)
rows = []database.ListUnresolvedUnlinkedBooksForUserRow{}
}
ignoredRows, ignoredErr := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if ignoredErr != nil {
log.Printf("ListIgnoredUnlinkedBooksForUser failed: %v", ignoredErr)
ignoredRows = []database.ListIgnoredUnlinkedBooksForUserRow{}
}
books := make([]templates.UnlinkedBookData, 0, len(rows))
for _, r := range rows {
@@ -796,14 +834,133 @@ func registerFrontendRoutes(cfg *Config) {
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
ignoredBooks := make([]templates.UnlinkedBookData, 0, len(ignoredRows))
for _, r := range ignoredRows {
ignoredBooks = append(ignoredBooks, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, user.Timezone),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedBooks(user, books).Render(c.Request().Context(), &buf); renderErr != nil {
if renderErr := templates.UnlinkedBooks(user, books, ignoredBooks).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
// Unlinked-book dismiss/delete actions (htmx: the buttons post/delete
// and swap in the re-rendered section). Authorization is row → device
// → requesting user (admin may act on anyone's rows); a non-htmx hit
// just bounces back to the page. These are page-scoped SSR routes, so
// they are intentionally NOT under /api and not in the OpenAPI docs.
unlinkedSectionHTML := func(c *echo.Context, dbUser database.Users) error {
userUUID, err := uuid.Parse(dbUser.ID.String())
if err != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
rows, err := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
}
ignoredRows, err := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
}
books := make([]templates.UnlinkedBookData, 0, len(rows))
for _, r := range rows {
books = append(books, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
ignoredBooks := make([]templates.UnlinkedBookData, 0, len(ignoredRows))
for _, r := range ignoredRows {
ignoredBooks = append(ignoredBooks, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedBooksSection(books, ignoredBooks).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
}
// authorizeUnlinkedAction resolves the :id row with its device owner
// and gates the action: nil row + status/message on failure.
authorizeUnlinkedAction := func(c *echo.Context, dbUser database.Users) (*database.GetUnlinkedBookWithOwnerRow, int, string) {
rowID, err := uuid.Parse(c.Param("id"))
if err != nil {
return nil, http.StatusBadRequest, "<div>Invalid entry id</div>"
}
row, err := cfg.Queries.GetUnlinkedBookWithOwner(c.Request().Context(), pgtype.UUID{Bytes: rowID, Valid: true})
if err != nil {
return nil, http.StatusNotFound, "<div>Entry not found</div>"
}
ownerID := uuid.UUID(row.UserID.Bytes).String()
if ownerID != dbUser.ID.String() && dbUser.Role != "admin" {
return nil, http.StatusForbidden, "<div>Not allowed</div>"
}
return &row, 0, ""
}
for _, action := range []struct {
method string
path string
run func(ctx context.Context, rowID pgtype.UUID) error
}{
{"POST", "/unlinked-books/:id/ignore", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.IgnoreUnlinkedBook(ctx, rowID)
}},
{"POST", "/unlinked-books/:id/unignore", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.UnignoreUnlinkedBook(ctx, rowID)
}},
{"DELETE", "/unlinked-books/:id", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.DeleteUnlinkedBook(ctx, rowID)
}},
} {
method, path, run := action.method, action.path, action.run
frontendProtected.Add(method, path, func(c *echo.Context) error {
dbUser, ok := c.Get("user").(database.Users)
if !ok {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
row, status, msg := authorizeUnlinkedAction(c, dbUser)
if row == nil {
return c.HTML(status, msg)
}
if err := run(c.Request().Context(), row.ID); err != nil {
log.Printf("unlinked-book action %s %s failed: %v", method, path, err)
return c.HTML(http.StatusInternalServerError, "<div>Action failed</div>")
}
if c.Request().Header.Get("HX-Request") != "true" {
return c.Redirect(http.StatusSeeOther, "/unlinked-books")
}
return unlinkedSectionHTML(c, dbUser)
})
}
// Unlinked-book match results partial: the match cascade for one device
// file, rendered server-side and scoped to the requester's visible
// libraries (absent-from-results) — rendering-ownership: templ renders,
@@ -1287,6 +1444,67 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String())
}))
// Content partial for the retry queue: after the client drains its
// sequential retry queue it swaps this in over #issues-content so
// resolved issues drop off server-side (unlinked_books matches
// partial precedent). Same family-lens gate as the page.
frontendProtected.GET("/admin/libraries/:id/issues/content", handlers.AdminMiddleware(func(c *echo.Context) error {
libraryID := c.Param("id")
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return renderErrorPage(c, "Invalid library ID", "invalid_id")
}
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
if visErr != nil {
return renderErrorPage(c, "Error checking library access", "visibility_error")
}
if !visibleSet[libraryID] {
return renderErrorPage(c, "Library not accessible", "library_not_accessible")
}
stats, err := cfg.Queries.GetProcessingIssueStats(c.Request().Context(), uuidToPGType(libUUID))
if err != nil {
return renderErrorPage(c, "Error loading stats", "stats_load_error")
}
dbIssues, err := cfg.Queries.ListProcessingIssuesByLibrary(c.Request().Context(), uuidToPGType(libUUID))
if err != nil {
return renderErrorPage(c, "Error loading issues", "issues_load_error")
}
issues := make([]templates.ProcessingIssueData, len(dbIssues))
for i, issue := range dbIssues {
issueUUID, _ := uuid.FromBytes(issue.ID.Bytes[0:16])
itemID := ""
if issue.MediaItemID.Valid {
itemID = uuid.UUID(issue.MediaItemID.Bytes).String()
}
title := issue.Title
if title == "" {
title = issue.FilePath
}
issues[i] = templates.ProcessingIssueData{
ID: issueUUID.String(),
MediaItemID: itemID,
Title: title,
FilePath: issue.FilePath,
FormatGroup: issue.FormatGroup,
LibraryTypeName: issue.LibraryTypeName,
IssueType: issue.IssueType,
IssueDescription: issue.IssueDescription,
Severity: issue.Severity,
CreatedAt: issue.CreatedAt.Time,
}
}
statsData := templates.IssueStats{
ErrorCount: stats.ErrorCount,
WarningCount: stats.WarningCount,
InfoCount: stats.InfoCount,
}
var buf bytes.Buffer
if err := templates.ProcessingIssuesContent(libraryID, issues, statsData).Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
// Admin hash conflicts page: content-duplicate groups flagged during hash
// backfill or rescan, resolved by keeping all copies or merging into one.
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
+1
View File
@@ -67,6 +67,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
LastName: utils.PgTextString(userDB.LastName),
CreatedAt: userDB.CreatedAt.Time,
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
KeepScreenAwake: userDB.KeepScreenAwake,
}, nil
}
+1
View File
@@ -39,6 +39,7 @@ func registerLibraryRoutes(cfg *Config) {
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue)
adminLibrary.POST("/:id/issues/:issueId/retry", cfg.ProcessingIssuesHandler.RetryProcessingIssue)
adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
libraryID := c.Param("id")
+1
View File
@@ -74,6 +74,7 @@ func registerMediaRoutes(cfg *Config) {
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
admin.POST("/media-items/:id/write-metadata", cfg.MediaHandler.WriteMetadataToFile)
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems)
admin.POST("/media-items/:id/unarchive", cfg.MediaHandler.UnarchiveMediaItem)
+1
View File
@@ -100,6 +100,7 @@ func registerReaderRoutes(cfg *Config) {
TotalCharacters: mediaItem.TotalCharacters.Int64,
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
KeepScreenAwake: user.KeepScreenAwake,
}
// Render template
var buf bytes.Buffer
+17
View File
@@ -11,9 +11,13 @@ import (
"bookhoard/templates"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -349,6 +353,19 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
}
})
// Bundle stamps: ?v=<content-hash> per bundle, computed at startup —
// a rebuild that changes a bundle's bytes changes its cache key with
// no manual stamp bump (see templates/bundles.go; the manual ?v=N
// stamp is how a fixed search dropdown stayed broken for a deploy).
for _, bundle := range []string{"main.js", "reader.js"} {
if data, err := os.ReadFile(filepath.Join("web/static", bundle)); err == nil {
sum := sha256.Sum256(data)
templates.SetBundleVersion(bundle, hex.EncodeToString(sum[:])[:8])
} else {
log.Printf("bundle %s not found for stamping: %v", bundle, err)
}
}
e.Static("/static", "web/static")
// Catch-all 404 handler - must be last
+8 -2
View File
@@ -90,9 +90,15 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
// Render using BooksGrid template (or BookPickerGrid for collection picker)
var buf bytes.Buffer
if c.QueryParam("show_checkbox") == "true" {
switch {
case c.QueryParam("show_checkbox") == "true":
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
} else {
case c.QueryParam("layout") == "dropdown":
// Header-search dropdown: compact templ rows (rendering
// ownership) carrying the viewer's progress/completed overlay.
// Replaces the old innerHTML string renderer in search.ts.
err = templates.SearchResults(bookInfoList, c.QueryParam("q")).Render(c.Request().Context(), &buf)
default:
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
}
if err != nil {
+147 -40
View File
@@ -831,18 +831,70 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
// retention window (ARCHIVE_RETENTION_DAYS; 0 = manual purge only).
// Every branch logs - the previous hard-delete cleanup failed silently
// and left orphaned rows undetected.
// Group this scan's folders by owning library: the reconciliation is
// per LIBRARY, comparing every library item against the union of all
// its folders' walks. The old per-folder loop listed the whole
// library's items against each single folder's walk, so with two
// folders every item living under the other folder was flagged
// missing on every scan (prod: 743/743 audiobooks stuck at
// "missing (1/2)", unarchivable only because the walk kept clearing
// them) — and with three or more folders earlier iterations' marks
// crossed later ones' walks and archived real content mid-pass.
scannedSet := make(map[string]bool, len(s.folders))
for _, scanned := range s.folders {
scannedSet[scanned] = true
}
type archiveScope struct {
libraryID pgtype.UUID
name string
folders []string
}
scopes := make([]archiveScope, 0, len(s.folders))
scopeIndex := make(map[pgtype.UUID]int, len(s.folders))
for _, folder := range s.folders {
lib, err := s.db.GetLibraryByFolder(ctx, folder)
if err != nil {
fmt.Printf("[ARCHIVE] Warning: no library found for folder %s, skipping it in archive pass: %v\n", folder, err)
continue
}
idx, seen := scopeIndex[lib.LibraryID]
if !seen {
idx = len(scopes)
scopeIndex[lib.LibraryID] = idx
scopes = append(scopes, archiveScope{libraryID: lib.LibraryID, name: lib.Name})
}
scopes[idx].folders = append(scopes[idx].folders, folder)
}
for _, scope := range scopes {
if ctx.Err() != nil {
fmt.Printf("[ARCHIVE] Scan cancelled, stopping archive pass\n")
return ctx.Err()
}
libraryID := scope.libraryID
lib, err := s.db.GetLibraryByFolder(ctx, folder)
if err != nil {
fmt.Printf("[ARCHIVE] Warning: no library found for folder %s, skipping archive pass: %v\n", folder, err)
// Partial-scope guard (per-folder scans, 2026-10): the
// reconciliation compares EVERY library item against this scan's
// disk walk — a scan that covered only some of the library's
// registered folders (the LibraryPanel per-folder Scan buttons)
// saw nothing from the others and would mark their items missing
// → archived. Reconcile only when the scan covered all of them.
registeredFolders, regErr := s.db.GetLibraryFolders(ctx, libraryID)
if regErr != nil {
fmt.Printf("[ARCHIVE] Warning: failed to list folders for library, skipping archive pass: %v\n", regErr)
continue
}
covered := 0
for _, rf := range registeredFolders {
if scannedSet[rf.FolderPath] {
covered++
}
}
if covered < len(registeredFolders) {
fmt.Printf("[ARCHIVE] Skipping reconciliation for %s: scan covered %d of %d registered folders\n",
scope.name, covered, len(registeredFolders))
continue
}
libraryID := lib.LibraryID
dbItems, err := s.db.ListMediaItemsByLibraryIncludingArchived(ctx, libraryID)
if err != nil {
@@ -850,26 +902,39 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
continue
}
// Build set of scanned file paths for this folder
// Build the set of scanned paths across ALL of this library's
// folders.
scannedPaths := make(map[string]bool)
if err := filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
walkFailed := false
for _, folder := range scope.folders {
if err := filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !d.IsDir() && s.isScannableFile(path) {
scannedPaths[s.getRelativePath(path)] = true
}
return nil
}); err != nil {
fmt.Printf("[ARCHIVE] Warning: failed to walk directory %s, skipping archive pass: %v\n", folder, err)
walkFailed = true
break
}
if !d.IsDir() && s.isScannableFile(path) {
scannedPaths[s.getRelativePath(path)] = true
}
return nil
}); err != nil {
fmt.Printf("[ARCHIVE] Warning: failed to walk directory %s, skipping archive pass: %v\n", folder, err)
continue // Skip to next folder to avoid false archivals
}
if walkFailed {
continue // Skip this library to avoid false archivals
}
// Folder-book roots count as present: the item's file_path is the
// DIRECTORY, which the file walk above never emits.
// DIRECTORY, which the file walk above never emits. Attribution is
// boundary-safe — a sibling folder whose name merely extends this
// one's must not contribute its roots here.
for root := range s.audioBookRoots {
if strings.HasPrefix(root, folder) {
scannedPaths[s.getRelativePath(root)] = true
for _, folder := range scope.folders {
if folderContains(folder, root) {
scannedPaths[s.getRelativePath(root)] = true
break
}
}
}
@@ -927,7 +992,7 @@ func (s *MediaScanner) isScannableFile(path string) bool {
// Find which folder this file belongs to
var folder string
for _, f := range s.folders {
if strings.HasPrefix(path, f) {
if folderContains(f, path) {
folder = f
break
}
@@ -1090,7 +1155,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
folderLibrary := s.folderLibrary
s.scanStateMu.RUnlock()
for _, folder := range s.folders {
if !strings.HasPrefix(path, folder) {
if !folderContains(folder, path) {
continue
}
if libID, cached := folderLibrary[folder]; cached {
@@ -1233,7 +1298,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
// Use the root folder that contains this file
var rootFolder string
for _, folder := range s.folders {
if strings.HasPrefix(path, folder) {
if folderContains(folder, path) {
rootFolder = folder
break
}
@@ -1577,6 +1642,21 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
}
if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
fmt.Printf("Warning: failed to update media item: %v\n", err)
// Ledger (Phase 11): the item exists — record item-backed so
// the Processing Issues page flags the degraded metadata and
// offers a retry, instead of failing silently on every scan
// (the 2026-10 Crusader genre-22001 loop did exactly that).
// Same type the sidecar-change path uses; resolveIssuesForItem
// clears it when an update finally succeeds.
if logErr := s.LogProcessingIssue(ctx,
uuid.UUID(existingItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
"metadata_extraction_failed",
fmt.Sprintf("Metadata update failed; row left unchanged: %v", err),
"warning"); logErr != nil {
fmt.Printf("Warning: failed to record update issue for %s: %v\n", path, logErr)
}
} else {
s.resolveIssuesForItem(ctx, existingItem.ID)
}
// The bytes changed, so any stored hash is stale.
s.recomputeHashInfo(ctx, entry.ID, libraryID, path)
@@ -1643,6 +1723,18 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
}
if err := s.updateMediaItemWithMetadata(ctx, existingItem, path, metadata); err != nil {
fmt.Printf("Warning: failed to update media item after sidecar change %s: %v\n", path, err)
// Ledger: same item-backed record as the size-change path —
// a failed re-extraction must be visible and retryable, not a
// scan-log whisper.
if logErr := s.LogProcessingIssue(ctx,
uuid.UUID(existingItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
"metadata_extraction_failed",
fmt.Sprintf("Metadata update after sidecar change failed; row left unchanged: %v", err),
"warning"); logErr != nil {
fmt.Printf("Warning: failed to record sidecar-update issue for %s: %v\n", path, logErr)
}
} else {
s.resolveIssuesForItem(ctx, existingItem.ID)
}
// Playlist sync on the change path (plan Correction 2): a
// "(Reading Order)" entry added to an EXISTING book's sidecar
@@ -1719,23 +1811,24 @@ func applyTitleDerivedSeries(metadata *MediaMetadata) {
// extractCalibreSidecar checks for and parses a Calibre metadata.opf sidecar file
func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
// Get directory of media file
// Per-file sidecar FIRST (<basename>.opf, the writer's multi-book
// safe name), then Calibre's dir-scoped metadata.opf.
dir := filepath.Dir(path)
opfPath := filepath.Join(dir, "metadata.opf")
// Check if sidecar exists
if _, err := os.Stat(opfPath); os.IsNotExist(err) {
return nil // No sidecar, not an error
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
for _, name := range []string{base + ".opf", "metadata.opf"} {
opfPath := filepath.Join(dir, name)
if _, err := os.Stat(opfPath); err != nil {
continue
}
// Parse sidecar
metadata, err := s.parseCalibreMetadataOPF(opfPath)
if err != nil {
fmt.Printf("Warning: failed to parse Calibre metadata.opf: %v\n", err)
continue // fall back to the next candidate / embedded
}
return metadata
}
// Parse sidecar
metadata, err := s.parseCalibreMetadataOPF(opfPath)
if err != nil {
fmt.Printf("Warning: failed to parse Calibre metadata.opf: %v\n", err)
return nil // Parsing failed, fall back to embedded
}
return metadata
return nil
}
// applyEmbeddedCoverFallback extracts a cover from the media file itself when
@@ -3997,7 +4090,7 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
// could clear series/author that a sidecar didn't carry (Libation
// writes "series": null while the folder layout knows the series).
for _, folder := range s.folders {
if strings.HasPrefix(path, folder) {
if folderContains(folder, path) {
if folderMetadata := s.extractFolderStructureMetadata(path, folder); folderMetadata != nil {
if metadata.Title == "" && folderMetadata.Title != "" {
metadata.Title = folderMetadata.Title
@@ -5006,13 +5099,27 @@ func (s *MediaScanner) extractHashInfo(filePath string) (*HashInfo, *FormatInfo,
return hashInfo, formatInfo, nil
}
// folderContains reports whether path is folder itself or lies beneath it
// at a directory boundary. This is the only path-correct containment test:
// a bare strings.HasPrefix(path, folder) also matches string-prefix
// siblings ("/app/audiobooks" claims "/app/audiobookssd/..."), which once
// sliced a stray "sd/" into every stored relative path for that library
// and resolved those files against the wrong root (folder ownership,
// library attribution, extension allowlisting, cover 404s).
func folderContains(folder, path string) bool {
folder = filepath.Clean(folder)
return path == folder || strings.HasPrefix(path, folder+string(filepath.Separator))
}
func (s *MediaScanner) getRelativePath(absolutePath string) string {
// Get the base folder paths from scanner
for _, baseFolder := range s.folders {
// Check if path is within this base folder
if relPath, ok := strings.CutPrefix(absolutePath, baseFolder); ok {
return strings.TrimPrefix(relPath, "/")
// Check if path is within this base folder — boundary-aware, so a
// sibling whose name merely extends ours can never be cut.
if !folderContains(baseFolder, absolutePath) {
continue
}
return strings.TrimPrefix(absolutePath[len(filepath.Clean(baseFolder)):], "/")
}
// Fallback: if no match, return as-is (shouldn't happen)
return absolutePath
+19 -2
View File
@@ -30,6 +30,8 @@ import (
"strconv"
"strings"
"time"
"bookhoard/internal/utils"
)
// AudioChapter is the uniform chapter entry for every audiobook file model.
@@ -394,7 +396,8 @@ func marshalAudioMetadata(payload *AudioMetadataPayload) []byte {
// ----:com.apple.iTunes:SUBTITLE → joined into the title
// ("Title: Subtitle"), mirroring the ABS sidecar path
// ----:com.apple.iTunes:ASIN / ABRIDGED → asin / abridged flag
// date → publish date; genre → genre
// date → publish date; genre → smart-genre (shelf-or-empty column,
// split candidates to tags — see pickSmartGenre)
//
// Abridged is the one field where an explicit sidecar false must not be
// overridden by a tag — the payload pointer marks sidecar presence.
@@ -472,7 +475,21 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
}
}
if metadata.Genre == "" {
metadata.Genre = report.tag("genre")
// Same doctrine as the ABS sidecar path (ab2a5568): the raw file
// atom is a comma-joined category blob ("Politicians, Presidents &
// Heads of State, United States, ..." — Audible ladders, often past
// 100 chars). Shelf-or-empty wins the genre column; every split
// candidate lands in tags. No shelf match → the column stays empty
// and the blob is tags-only, never a VARCHAR-overflowing INSERT.
if genreTag := report.tag("genre"); genreTag != "" {
genre, all := pickSmartGenre([]string{genreTag})
if genre != "" {
metadata.Genre = genre
}
if len(all) > 0 {
metadata.Tags = utils.NormalizeTags(append(metadata.Tags, all...))
}
}
}
}
@@ -5,6 +5,7 @@ package services
// and tag-based track ordering.
import (
"slices"
"os"
"path/filepath"
"strings"
@@ -178,8 +179,14 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
if meta.PublishDate.Year() != 2020 {
t.Fatalf("publish date = %v", meta.PublishDate)
}
if meta.Genre != "Audiobook" {
t.Fatalf("genre = %q", meta.Genre)
// Smart-genre routing (2026-10 Crusader fix): "Audiobook" is a
// DEMOTED genre (genreDemoted) — it is tags-only and never wins
// the column, mirroring the sidecar path's pickSmartGenre.
if meta.Genre != "" {
t.Fatalf("demoted genre must not win the column, got %q", meta.Genre)
}
if !slices.Contains(meta.Tags, "Audiobook") {
t.Fatalf("demoted genre must stay in tags, got %v", meta.Tags)
}
})
@@ -214,6 +221,46 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
t.Fatal("nil report must change nothing")
}
})
t.Run("audible genre blob splits into tags, column stays empty when no shelf matches", func(t *testing.T) {
// The 2026-10 Crusader regression: Libation writes the Audible
// category ladder as one comma-joined atom — 111 chars here — and
// the fallback used to capture it VERBATIM into media_items.genre,
// failing the import/update with SQLSTATE 22001 (VARCHAR(100)).
// The genre column takes a known shelf or stays empty; the split
// candidates are tags-only, exactly like the sidecar path.
crusader := "Politicians, Presidents & Heads of State, United States, Russia, Communism & Socialism, International Relations"
if len(crusader) <= 100 {
t.Fatalf("fixture must exceed the old VARCHAR(100) limit, len=%d", len(crusader))
}
meta := &MediaMetadata{}
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{"genre": crusader}))
if len(meta.Genre) > 100 {
t.Fatalf("genre column value must never exceed 100 chars, got %d: %q", len(meta.Genre), meta.Genre)
}
if meta.Genre != "" {
t.Fatalf("no candidate is a known shelf — column must stay empty, got %q", meta.Genre)
}
// NormalizeTags titlecases: "of" → "Of" is the shipped display form.
for _, want := range []string{"Politicians", "Presidents & Heads Of State", "United States", "Russia", "Communism & Socialism", "International Relations"} {
if !slices.Contains(meta.Tags, want) {
t.Fatalf("tags must contain split candidate %q, got %v", want, meta.Tags)
}
}
})
t.Run("known shelf wins the column from a genre blob", func(t *testing.T) {
meta := &MediaMetadata{}
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
"genre": "Epic, Action & Adventure, Military, Sword & Sorcery",
}))
if meta.Genre == "" {
t.Fatalf("expected a lexicon shelf from the blob, tags=%v", meta.Tags)
}
if !slices.Contains(meta.Tags, "Epic") || !slices.Contains(meta.Tags, "Military") {
t.Fatalf("split candidates must land in tags, got %v", meta.Tags)
}
})
}
func TestParseTagOrdinal(t *testing.T) {
@@ -178,7 +178,7 @@ func (s *MediaScanner) maybeProcessAudioBook(ctx context.Context, path string) (
// so any scannable audio file belongs to one.
rootFolder := ""
for _, folder := range s.folders {
if strings.HasPrefix(path, folder) {
if folderContains(folder, path) {
rootFolder = folder
break
}
@@ -731,7 +731,7 @@ func (s *MediaScanner) rescanMultiFileAudioBook(ctx context.Context, item databa
rootFolder := ""
for _, folder := range s.folders {
if strings.HasPrefix(fullPath, folder) {
if folderContains(folder, fullPath) {
rootFolder = folder
break
}
@@ -0,0 +1,134 @@
package services
import "testing"
// Regression tests for directory-boundary folder matching.
//
// getRelativePath (and the folder-membership checks across the scanner)
// once used bare string-prefix matching, so a registered folder whose path
// was a string prefix of a sibling's ("/app/audiobooks" vs
// "/app/audiobookssd") claimed the sibling's files: relative paths were
// cut mid-component into "sd/..."-style garbage, stored that way, and
// resolved against the wrong root — covers 404ing while the bytes sat one
// directory away. folderContains pins the rule: a folder only ever claims
// itself or paths beneath it at a real separator boundary, regardless of
// names, registration order, or trailing slashes.
func TestFolderContains(t *testing.T) {
tests := []struct {
name string
folder string
path string
want bool
}{
{"exact root", "/app/books", "/app/books", true},
{"direct child", "/app/books", "/app/books/x.epub", true},
{"deep child", "/app/books", "/app/books/a/b/c.epub", true},
{"sibling extension", "/app/books", "/app/books2", false},
{"sibling extension child", "/app/books", "/app/books2/x.epub", false},
{"mid-word sibling", "/data/lib", "/data/library/x.epub", false},
{"suffix without separator", "/app/books", "/app/books-backup/x.epub", false},
{"unrelated", "/app/books", "/opt/other/x.epub", false},
{"prefix inside path segment", "/app/books", "/app/series/books/x.epub", false},
{"trailing slash in folder", "/app/books/", "/app/books/x.epub", true},
{"trailing slash exact", "/app/books/", "/app/books", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := folderContains(tt.folder, tt.path); got != tt.want {
t.Errorf("folderContains(%q, %q) = %v, want %v", tt.folder, tt.path, got, tt.want)
}
})
}
}
func TestGetRelativePathFolderBoundary(t *testing.T) {
// The prod trap: /app/audiobooks registered before /app/audiobookssd.
// String-prefix cutting produced "sd/Brian Jacques/..." for every file
// in the second folder; the boundary rule must make that impossible.
newScanner := func(folders []string) *MediaScanner {
s := NewMediaScanner(nil)
s.folders = folders // SetFolders requires a live DB; tests only need path relativization
return s
}
siblingFolders := []string{"/app/audiobooks", "/app/audiobookssd"}
tests := []struct {
name string
folders []string
path string
want string
}{
{
name: "sibling-prefix folder keeps its full name in relative path",
folders: siblingFolders,
path: "/app/audiobookssd/Brian Jacques/Redwall/Book 01 - Redwall/file.m4b",
want: "Brian Jacques/Redwall/Book 01 - Redwall/file.m4b",
},
{
name: "first registered folder unaffected",
folders: siblingFolders,
path: "/app/audiobooks/Author/Series/file.mp3",
want: "Author/Series/file.mp3",
},
{
name: "exact root yields empty path",
folders: siblingFolders,
path: "/app/audiobookssd",
want: "",
},
{
name: "order independence: sibling registered first",
folders: []string{"/app/audiobookssd", "/app/audiobooks"},
path: "/app/audiobookssd/Brian Jacques/Redwall/Book 01 - Redwall/file.m4b",
want: "Brian Jacques/Redwall/Book 01 - Redwall/file.m4b",
},
{
name: "order independence: first folder still correct when second registered first",
folders: []string{"/app/audiobookssd", "/app/audiobooks"},
path: "/app/audiobooks/Author/Series/file.mp3",
want: "Author/Series/file.mp3",
},
{
name: "trailing slash on registered folders",
folders: []string{"/app/audiobooks/", "/app/audiobookssd/"},
path: "/app/audiobookssd/Brian Jacques/file.m4b",
want: "Brian Jacques/file.m4b",
},
{
name: "mid-word sibling folders",
folders: []string{"/data/lib", "/data/library"},
path: "/data/library/author/book.epub",
want: "author/book.epub",
},
{
name: "author/series/title layout preserved under true root",
folders: siblingFolders,
path: "/app/audiobookssd/Brian Jacques/Redwall/Book 14 - The Taggerung/Taggerung.mp3",
want: "Brian Jacques/Redwall/Book 14 - The Taggerung/Taggerung.mp3",
},
{
name: "path outside every folder returned as-is",
folders: siblingFolders,
path: "/app/audiobooksBackup/x.mp3",
want: "/app/audiobooksBackup/x.mp3",
},
{
name: "unrelated path returned as-is",
folders: siblingFolders,
path: "/elsewhere/x.mp3",
want: "/elsewhere/x.mp3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newScanner(tt.folders)
if got := s.getRelativePath(tt.path); got != tt.want {
t.Errorf("getRelativePath(%q) with folders %v = %q, want %q", tt.path, tt.folders, got, tt.want)
}
})
}
}
+92
View File
@@ -0,0 +1,92 @@
package services
// Individual-item retry (2026-10 Processing Issues work): the per-issue
// "Retry" button re-runs the failed work through the individual scanner
// instead of a whole-library rescan. Two shapes mirror the two ledger
// entry kinds:
//
// - item-backed issues → RescanMediaItem (the book-detail rescan
// mechanism; folder resolution, multi-file audiobook routing and the
// write-after-success guards all live there already)
// - row-less import_failed issues → RetryFailedImport below: the
// watcher's standalone per-file path (processMediaFile with no bulk
// scan state), scoped to exactly one file.
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/jackc/pgx/v5/pgtype"
)
// ErrRetryFileMissing reports a retry whose file is no longer on disk —
// the handler maps it to 409 so the UI can say "file gone" rather than
// "retry failed".
var ErrRetryFileMissing = errors.New("media file not found on disk")
// RetryFailedImport re-imports a single file whose media_items row was
// never created (a row-less import_failed ledger entry). It resolves the
// library's folders itself (the RescanMediaItem precedent) so relative
// paths stay library-root-relative, then runs the ordinary single-file
// import chain — sidecars → folder structure → tags → filename — with
// the create path's own ledger self-heal (resolveIssuesForPath) closing
// the issue on success.
func (s *MediaScanner) RetryFailedImport(ctx context.Context, libraryID pgtype.UUID, relativePath string) error {
if !libraryID.Valid || relativePath == "" {
return fmt.Errorf("library id and file path are required")
}
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
if err != nil || len(folders) == 0 {
return fmt.Errorf("no library folders found for library")
}
folderPaths := make([]string, 0, len(folders))
for _, folder := range folders {
folderPaths = append(folderPaths, folder.FolderPath)
}
s.folders = folderPaths
// processMediaFile prefers the default library over per-folder
// resolution; setting it keeps a path that straddles folder roots
// attributed to the ledger entry's library.
s.SetLibraryID(libraryID)
var fullPath string
for _, folder := range folders {
candidate := filepath.Join(folder.FolderPath, relativePath)
if _, statErr := os.Stat(candidate); statErr == nil {
fullPath = candidate
break
}
}
if fullPath == "" {
return fmt.Errorf("%w: %s", ErrRetryFileMissing, relativePath)
}
imported, err := s.processMediaFile(ctx, fullPath)
if err != nil {
return err
}
if !imported {
return fmt.Errorf("file was skipped (not a scannable media file, or identical content already registered)")
}
return nil
}
// RetryMediaItemIssue re-extracts one item-backed issue's media item
// through the book-detail rescan mechanism and closes the issue when the
// rescan succeeds. Kept next to RetryFailedImport so the retry handler
// reads as the one piece of new surface it is.
func (s *MediaScanner) RetryMediaItemIssue(ctx context.Context, mediaItemID pgtype.UUID) error {
if err := s.RescanMediaItem(ctx, mediaItemID, false); err != nil {
return err
}
// RescanMediaItem doesn't self-heal the ledger; the retry that was
// explicitly asked for does.
if _, err := s.db.ResolveOpenProcessingIssuesForItem(ctx, mediaItemID); err != nil {
fmt.Printf("Warning: retry resolved item %s but failed to close its issues: %v\n", mediaItemID, err)
}
return nil
}
+939
View File
@@ -0,0 +1,939 @@
package services
import (
"archive/zip"
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"math"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
// MetadataWriter persists the item's CURRENT database metadata back into
// the artifact on disk, so the library stays portable (Calibre, phone
// players, other servers). 2026-10-27 feature (user request): embed into
// formats that carry metadata, write a Calibre-style sidecar for those
// that don't.
//
// Safety contract:
// - temp file + atomic rename; the original is NEVER truncated on
// failure (a failed write must not destroy a book)
// - the write mirrors what the scanner's read chain parses
// (parseOPFContent), so a write + rescan round-trips
// - metadata_overrides are left untouched: after a successful write
// the file and the DB agree, and the overrides keep stale sidecars
// from clobbering later rescans
//
// Consequences that are inherent to editing files in place (documented
// in docs/user/calibre-integration.md): the mtime change makes the
// watcher re-extract (wanted), and the SHA-256 changes (dedup/move
// detection re-keys; Kobo device sidecars re-key on next sync).
// MetadataWriteTarget names where a format's metadata landed.
type MetadataWriteTarget string
const (
// MetadataTargetEmbeddedOPF: the OPF inside the EPUB was rewritten.
MetadataTargetEmbeddedOPF MetadataWriteTarget = "embedded-opf"
// MetadataTargetSidecarOPF: a Calibre-style sidecar was written next
// to the file (formats that can't carry metadata: PDF, comics,
// folder books).
MetadataTargetSidecarOPF MetadataWriteTarget = "sidecar-opf"
// MetadataTargetAudioTags: the container's own tags were rewritten
// via ffmpeg stream-copy (single-file MP3/FLAC/M4A/M4B — 5b).
MetadataTargetAudioTags MetadataWriteTarget = "audio-tags"
// MetadataTargetComicInfo: ComicInfo.xml inside the CBZ was
// injected/replaced (5c). CBR/CB7 stay on the sidecar path — RAR
// and 7z archives can't be rewritten in place.
MetadataTargetComicInfo MetadataWriteTarget = "comicinfo"
)
// audioTagExts is the single-file set the tag writer claims; multi-file
// (folder) audio stays on the sidecar path.
var audioTagExts = map[string]bool{
".mp3": true, ".flac": true, ".m4a": true, ".m4b": true,
}
// MetadataWriteResult reports what the write did, for the UI toast and
// the response contract.
type MetadataWriteResult struct {
Target MetadataWriteTarget `json:"target"`
Path string `json:"path"` // absolute path of the artifact written
Sidecar bool `json:"sidecar"`
FieldsWritten []string `json:"fields_written"`
}
// MetadataWriteTargetFor maps a file extension to the write target.
// The UI mirrors this to label the button; keep both in lockstep.
func MetadataWriteTargetFor(ext string) MetadataWriteTarget {
ext = strings.ToLower(strings.TrimSpace(ext))
switch {
case ext == ".epub" || ext == ".kepub":
return MetadataTargetEmbeddedOPF
case audioTagExts[ext]:
return MetadataTargetAudioTags
case ext == ".cbz":
return MetadataTargetComicInfo
default:
// CBR/CB7 (no in-place rewrite) and every other format take
// the sidecar.
return MetadataTargetSidecarOPF
}
}
// MetadataWriter carries the dependencies for file-writing operations.
type MetadataWriter struct {
db *database.Queries
libraries *LibraryService
}
func NewMetadataWriter(db *database.Queries) *MetadataWriter {
return &MetadataWriter{db: db, libraries: NewLibraryService(db)}
}
// WriteMetadataToFile dispatches on the item's format and persists the
// current DB values into the artifact on disk.
func (w *MetadataWriter) WriteMetadataToFile(ctx context.Context, itemID uuid.UUID) (*MetadataWriteResult, error) {
item, err := w.db.GetMediaItem(ctx, pgtype.UUID{Bytes: itemID, Valid: true})
if err != nil {
return nil, fmt.Errorf("media item not found: %w", err)
}
absPath, err := w.libraries.ResolveMediaPath(ctx, item.LibraryID, item.FilePath)
if err != nil {
return nil, fmt.Errorf("cannot resolve library folder: %w", err)
}
if _, err := os.Stat(absPath); err != nil {
return nil, fmt.Errorf("media file missing on disk: %w", err)
}
switch MetadataWriteTargetFor(filepath.Ext(absPath)) {
case MetadataTargetEmbeddedOPF:
path, err := w.writeEPUBMetadata(absPath, &item)
if err != nil {
return nil, err
}
return &MetadataWriteResult{
Target: MetadataTargetEmbeddedOPF,
Path: path,
FieldsWritten: metadataFieldNames(&item),
}, nil
case MetadataTargetAudioTags:
path, err := w.writeAudioTags(absPath, &item)
if err != nil {
return nil, err
}
return &MetadataWriteResult{
Target: MetadataTargetAudioTags,
Path: path,
FieldsWritten: audioFieldNames(&item, filepath.Ext(absPath)),
}, nil
case MetadataTargetComicInfo:
path, err := w.writeCBZComicInfo(absPath, &item)
if err != nil {
return nil, err
}
return &MetadataWriteResult{
Target: MetadataTargetComicInfo,
Path: path,
FieldsWritten: comicFieldNames(&item),
}, nil
default:
path, err := w.writeSidecarOPF(ctx, absPath, &item)
if err != nil {
return nil, err
}
return &MetadataWriteResult{
Target: MetadataTargetSidecarOPF,
Path: path,
Sidecar: true,
FieldsWritten: metadataFieldNames(&item),
}, nil
}
}
// writeSidecarOPF writes a Calibre-style OPF next to the media file.
// Naming: `metadata.opf` (Calibre's own convention) when the item is
// the only book in its directory; otherwise `<basename>.opf` so one
// book's sidecar can never poison its dir-mates' scans. The scanner
// reads both (basename first).
func (w *MetadataWriter) writeSidecarOPF(ctx context.Context, absPath string, item *database.MediaItems) (string, error) {
dir := filepath.Dir(absPath)
base := strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath))
others, err := w.countSiblingBooks(ctx, item)
if err != nil {
return "", fmt.Errorf("cannot check sibling books: %w", err)
}
name := "metadata.opf"
if others > 0 {
name = base + ".opf"
}
sidecarPath := filepath.Join(dir, name)
doc := buildSidecarOPFXML(item)
if err := atomicWrite(sidecarPath, []byte(doc)); err != nil {
return "", fmt.Errorf("failed to write sidecar: %w", err)
}
return sidecarPath, nil
}
// countSiblingBooks counts OTHER non-archived items that live in the
// same directory of the same library — the guard against writing a
// dir-scoped metadata.opf over a multi-book folder.
func (w *MetadataWriter) countSiblingBooks(ctx context.Context, item *database.MediaItems) (int, error) {
dir := filepath.ToSlash(filepath.Dir(item.FilePath))
siblings, err := w.db.CountOtherMediaItemsInDir(ctx, database.CountOtherMediaItemsInDirParams{
LibraryID: item.LibraryID,
ID: item.ID,
DirPrefix: pgtype.Text{String: dir + "/", Valid: true},
})
if err != nil {
return 0, err
}
return int(siblings), nil
}
// writeEPUBMetadata splices a freshly generated <metadata> block into
// the EPUB's existing OPF (manifest/spine/guide preserved untouched)
// and rewrites the zip atomically: every other entry is copied
// byte-identical, in original order with original compression — the
// mimetype entry must stay first and STORED.
func (w *MetadataWriter) writeEPUBMetadata(absPath string, item *database.MediaItems) (string, error) {
reader, err := zip.OpenReader(absPath)
if err != nil {
return "", fmt.Errorf("failed to open EPUB: %w", err)
}
defer reader.Close()
opfName := findOPFPathInZip(reader.File)
if opfName == "" {
return "", fmt.Errorf("EPUB has no OPF (container.xml missing rootfile)")
}
newOPF, err := spliceOPFMetadata(reader.File, opfName, item)
if err != nil {
return "", err
}
tmpPath := absPath + ".tmp-write"
if err := rewriteEPUBZip(reader.File, opfName, newOPF, tmpPath); err != nil {
os.Remove(tmpPath)
return "", err
}
if err := os.Rename(tmpPath, absPath); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("failed to replace EPUB: %w", err)
}
return absPath, nil
}
// spliceOPFMetadata returns the OPF with its <metadata> inner XML
// replaced. The ORIGINAL opening tag (with its namespaces and
// unique-identifier) is preserved so manifest/spine id references keep
// resolving.
func spliceOPFMetadata(entries []*zip.File, opfName string, item *database.MediaItems) ([]byte, error) {
var opfEntry *zip.File
for _, f := range entries {
if f.Name == opfName {
opfEntry = f
break
}
}
if opfEntry == nil {
return nil, fmt.Errorf("OPF %q not found in EPUB", opfName)
}
rc, err := opfEntry.Open()
if err != nil {
return nil, fmt.Errorf("failed to open OPF: %w", err)
}
original, err := io.ReadAll(rc)
closeErr := rc.Close()
if err != nil {
return nil, fmt.Errorf("failed to read OPF: %w", err)
}
if closeErr != nil {
return nil, fmt.Errorf("failed to close OPF: %w", closeErr)
}
metaStart := bytes.Index(original, []byte("<metadata"))
if metaStart == -1 {
return nil, fmt.Errorf("OPF has no <metadata> element")
}
openEnd := bytes.Index(original[metaStart:], []byte(">"))
if openEnd == -1 {
return nil, fmt.Errorf("OPF <metadata> tag malformed")
}
metaClose := bytes.Index(original[metaStart:], []byte("</metadata>"))
if metaClose == -1 {
return nil, fmt.Errorf("OPF has no </metadata> close")
}
openTag := original[metaStart : metaStart+openEnd+1]
inner := buildOPFMetadataInner(item) + originalIdentifiers(original[metaStart:metaStart+metaClose])
out := bytes.NewBuffer(nil)
out.Write(original[:metaStart])
out.Write(openTag)
out.WriteString("\n")
out.WriteString(inner)
out.Write(original[metaStart+metaClose:])
return out.Bytes(), nil
}
// originalIdentifiers extracts the old metadata's dc:identifier
// elements verbatim. The package's unique-identifier attribute
// references one of them, so replacing the metadata block must keep
// them (generated fields come FIRST, so the parser's first-wins reads
// the fresh ISBN; the originals remain for identifier stability).
func originalIdentifiers(oldMetadata []byte) string {
var b strings.Builder
rest := oldMetadata
for {
start := bytes.Index(rest, []byte("<dc:identifier"))
if start == -1 {
break
}
end := bytes.Index(rest[start:], []byte("</dc:identifier>"))
if end == -1 {
break
}
end += start + len("</dc:identifier>")
b.Write(rest[start:end])
b.WriteString("\n")
rest = rest[end:]
}
return b.String()
}
// rewriteEPUBZip copies every entry to dst, substituting opfName's
// content. Order, compression method and modes are preserved.
func rewriteEPUBZip(entries []*zip.File, opfName string, newOPF []byte, dst string) error {
return rewriteZipArchive(entries, opfName, newOPF, dst, "", nil)
}
// rewriteZipArchive is the generic form: substitute name's content, and
// (for the CBZ path, where ComicInfo.xml may not exist yet) append
// extraName/extraContent as a final entry when the archive lacks the
// substituted name.
func rewriteZipArchive(entries []*zip.File, name string, content []byte, dst string, extraName string, extraContent []byte) error {
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("failed to create temp archive: %w", err)
}
writer := zip.NewWriter(out)
substituted := false
for _, f := range entries {
header := f.FileHeader
header.Flags &= ^uint16(0x8) // drop data-descriptor flag; sizes are known upfront
var target io.Writer
if f.Name == name {
// Sizes/CRC recomputed by the writer for the new content.
header.CompressedSize64 = 0
header.UncompressedSize64 = 0
header.CompressedSize = 0
header.UncompressedSize = 0
header.CRC32 = 0
target, err = writer.CreateHeader(&header)
if err != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to stage substituted entry: %w", err)
}
if _, err := target.Write(content); err != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to write substituted entry: %w", err)
}
substituted = true
continue
}
target, err = writer.CreateHeader(&header)
if err != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to stage entry %s: %w", f.Name, err)
}
rc, openErr := f.Open()
if openErr != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to read entry %s: %w", f.Name, openErr)
}
if _, err := io.Copy(target, rc); err != nil {
rc.Close()
writer.Close()
out.Close()
return fmt.Errorf("failed to copy entry %s: %w", f.Name, err)
}
if err := rc.Close(); err != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to finish entry %s: %w", f.Name, err)
}
}
if !substituted && extraName != "" && extraContent != nil {
target, cerr := writer.Create(extraName)
if cerr != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to append entry %s: %w", extraName, cerr)
}
if _, werr := target.Write(extraContent); werr != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to write appended entry %s: %w", extraName, werr)
}
}
if err := writer.Close(); err != nil {
out.Close()
return fmt.Errorf("failed to finalize archive: %w", err)
}
if err := out.Sync(); err != nil {
out.Close()
return fmt.Errorf("failed to sync archive: %w", err)
}
return out.Close()
}
// atomicWrite writes via temp file + rename in the SAME directory so a
// crash or a full disk can never leave a truncated book (or sidecar)
// behind.
func atomicWrite(path string, data []byte) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".bookhoard-write-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
// 0644: sidecars/rewritten files must be readable by host-side
// tools sharing the volume (Calibre reads metadata.opf); a 0600
// temp default would lock everyone but the server out.
if err := tmp.Chmod(0o644); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
// ---- audio tags (5b): ffmpeg stream-copy rewrite ----
// audioFieldNames lists the fields the audio tag path round-trips —
// exactly the keys applyAudioTagFallbacks reads back (the ingestion
// chain's tags rung). Fields the file format can't carry per our own
// read chain (description, publisher, language, isbn, the full tag
// list) are deliberately NOT written: "applicable and correct" means
// nothing lands in a file the scanner would never find again.
// Subtitle rides MP3/FLAC only (the mp4 muxer in our image drops
// subtitle keys).
func audioFieldNames(item *database.MediaItems, ext string) []string {
fields := []string{"title"}
if item.Subtitle.Valid && item.Subtitle.String != "" &&
!strings.EqualFold(ext, ".m4a") && !strings.EqualFold(ext, ".m4b") {
fields = append(fields, "subtitle")
}
if len(item.Authors) > 0 || (item.Author.Valid && item.Author.String != "") {
fields = append(fields, "authors")
}
if len(item.Narrators) > 0 {
fields = append(fields, "narrators")
}
if item.Series.Valid && item.Series.String != "" {
fields = append(fields, "series")
}
if item.DatePublished.Valid {
fields = append(fields, "date_published")
}
if len(item.Tags) > 0 {
fields = append(fields, "genre")
}
return fields
}
// audioTagArgs maps the item's current values onto the container tag
// keys the scanner's own fallback chain reads (media_scanner_audio.go):
//
// title → title + album (players want title; the scanner's
// album rung names the book)
// subtitle → subtitle (MP3/FLAC only — the mp4 muxer in
// our image drops subtitle keys, and
// fields we can't read back don't
// get written)
// authors → artist (first) + album_artist (joined)
// narrators → composer (Audible's convention)
// series → grouping (series-part rides the iTunes atom)
// date → date; first tag → genre
func audioTagArgs(item *database.MediaItems, ext string) [][2]string {
var args [][2]string
add := func(k, v string) {
if strings.TrimSpace(v) != "" {
args = append(args, [2]string{k, v})
}
}
title := strings.TrimSpace(item.Title)
add("title", title)
add("album", title)
if item.Subtitle.Valid && !strings.EqualFold(ext, ".m4a") && !strings.EqualFold(ext, ".m4b") {
add("subtitle", strings.TrimSpace(item.Subtitle.String))
}
authors := item.Authors
if len(authors) == 0 && item.Author.Valid {
authors = []string{item.Author.String}
}
if len(authors) > 0 {
add("artist", strings.TrimSpace(authors[0]))
joined := make([]string, 0, len(authors))
for _, a := range authors {
a = strings.TrimSpace(a)
if a != "" {
joined = append(joined, a)
}
}
add("album_artist", strings.Join(joined, " & "))
}
if len(item.Narrators) > 0 {
narrators := make([]string, 0, len(item.Narrators))
for _, n := range item.Narrators {
n = strings.TrimSpace(n)
if n != "" {
narrators = append(narrators, n)
}
}
add("composer", strings.Join(narrators, ", "))
}
if item.Series.Valid {
add("grouping", strings.TrimSpace(item.Series.String))
if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 {
add("----:com.apple.itunes:series-part", strconvFormatFloat(item.SeriesNumber.Float64))
}
}
if item.DatePublished.Valid {
add("date", item.DatePublished.Time.Format("2006-01-02"))
}
if len(item.Tags) > 0 && strings.TrimSpace(item.Tags[0]) != "" {
add("genre", strings.TrimSpace(item.Tags[0]))
}
return args
}
// probeDurationSeconds ffprobes a file's duration (0 on any failure).
func probeDurationSeconds(path string) float64 {
out, err := exec.Command("ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path).Output()
if err != nil {
return 0
}
v, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil || v < 0 {
return 0
}
return v
}
// writeAudioTags rewrites the container's tags via ffmpeg stream copy:
// `-c copy` never re-encodes, `-map_metadata 0` preserves existing
// global tags, and `-map_chapters 0` keeps the m4b chapter list
// (chapters live in the header, not a stream). Attempt 1 copies every
// stream; attempt 2 — for real-world files carrying text/subtitle/data
// tracks the ipod muxer rejects ("Tag text incompatible with output
// codec id ...") — maps audio + attached pictures only. Every attempt
// is ffprobe-VALIDATED (duration within tolerance of the source)
// BEFORE the atomic rename; a failure never touches the original.
func (w *MetadataWriter) writeAudioTags(absPath string, item *database.MediaItems) (string, error) {
info, err := os.Stat(absPath)
if err != nil {
return "", fmt.Errorf("media file missing on disk: %w", err)
}
if info.IsDir() {
return "", fmt.Errorf("multi-file audio books take the sidecar path")
}
srcDuration := probeDurationSeconds(absPath)
ext := strings.ToLower(filepath.Ext(absPath))
tmpPath := absPath + ".tmp-write" + ext
// Shared args; each attempt appends its own stream mapping.
base := []string{"-hide_banner", "-loglevel", "error", "-y",
"-i", absPath,
"-map_metadata", "0", "-map_chapters", "0", "-c", "copy",
}
for _, kv := range audioTagArgs(item, ext) {
base = append(base, "-metadata", kv[0]+"="+kv[1])
}
// Stream maps, best fidelity first.
streamMaps := [][]string{
{"-map", "0"}, // every stream (covers, chapters-as-streams)
// Audio + attached pictures; drop text/subtitle/data tracks —
// the ipod muxer refuses mov_text-style streams outright.
{"-map", "0:a", "-map", "0:t?", "-dn", "-sn"},
}
var lastErr error
for i, streamMap := range streamMaps {
args := append(append([]string{}, base...), streamMap...)
args = append(args, tmpPath)
if out, err := exec.Command("ffmpeg", args...).CombinedOutput(); err != nil {
os.Remove(tmpPath)
lastErr = fmt.Errorf("ffmpeg tag rewrite failed (attempt %d): %s", i+1, strings.TrimSpace(string(out)))
continue
}
// Validation gate: the rewritten container must still hold the
// same audio (duration within 1s or 0.5%, whichever is looser).
outDuration := probeDurationSeconds(tmpPath)
if outDuration <= 0 {
os.Remove(tmpPath)
lastErr = fmt.Errorf("rewritten audio failed validation (ffprobe read no duration)")
continue
}
if srcDuration > 0 {
tolerance := math.Max(1.0, srcDuration*0.005)
if math.Abs(outDuration-srcDuration) > tolerance {
os.Remove(tmpPath)
lastErr = fmt.Errorf("rewritten audio failed validation (duration %.2fs → %.2fs)", srcDuration, outDuration)
continue
}
}
if err := os.Rename(tmpPath, absPath); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("failed to replace audio file: %w", err)
}
return absPath, nil
}
return "", lastErr
}
// ---- CBZ ComicInfo.xml (5c) ----
// comicFieldNames lists the fields the CBZ write round-trips through
// the scanner's ComicInfo mapping (media_scanner.go: mergeMetadata's
// comic block). Fractional series numbers are omitted — the schema's
// Number is an integer and truncating would round-trip a lie.
func comicFieldNames(item *database.MediaItems) []string {
fields := []string{"title"}
if item.Series.Valid && item.Series.String != "" {
fields = append(fields, "series")
if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 &&
item.SeriesNumber.Float64 == float64(int(item.SeriesNumber.Float64)) {
fields = append(fields, "series_number")
}
if item.SeriesCount.Valid && item.SeriesCount.Int32 > 0 {
fields = append(fields, "series_count")
}
}
if len(item.Authors) > 0 || (item.Author.Valid && item.Author.String != "") {
fields = append(fields, "authors")
}
if item.Description.Valid && item.Description.String != "" {
fields = append(fields, "description")
}
if len(item.Publishers) > 0 || (item.Publisher.Valid && item.Publisher.String != "") {
fields = append(fields, "publisher")
}
if item.Language.Valid && item.Language.String != "" {
fields = append(fields, "language")
}
if item.DatePublished.Valid {
fields = append(fields, "date_published")
}
if len(item.Tags) > 0 {
fields = append(fields, "tags")
}
if item.WebUrl.Valid && item.WebUrl.String != "" {
fields = append(fields, "web_url")
}
return fields
}
// buildComicInfoXML renders a ComicInfo.xml (schema v2.x) from the
// item's current values — the mirror of the scanner's
// extractMetadataFromArchive mapping. Empty values omit their element:
// absent means "no data", never "delete".
func buildComicInfoXML(item *database.MediaItems) string {
var b strings.Builder
b.WriteString(xml.Header)
b.WriteString(`<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">` + "\n")
tag := func(name, value string) {
if strings.TrimSpace(value) != "" {
b.WriteString("\t<" + name + ">" + escXML(value) + "</" + name + ">\n")
}
}
tagInt := func(name string, v int) {
if v > 0 {
b.WriteString("\t<" + name + ">" + strconv.Itoa(v) + "</" + name + ">\n")
}
}
tag("Title", strings.TrimSpace(item.Title))
tag("Series", strings.TrimSpace(item.Series.String))
if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 &&
item.SeriesNumber.Float64 == float64(int(item.SeriesNumber.Float64)) {
tagInt("Number", int(item.SeriesNumber.Float64))
}
if item.SeriesCount.Valid {
tagInt("Count", int(item.SeriesCount.Int32))
}
if item.Volume.Valid {
tagInt("Volume", int(item.Volume.Int32))
}
authors := item.Authors
if len(authors) == 0 && item.Author.Valid {
authors = []string{item.Author.String}
}
joinedAuthors := make([]string, 0, len(authors))
for _, a := range authors {
a = strings.TrimSpace(a)
if a != "" {
joinedAuthors = append(joinedAuthors, a)
}
}
tag("Writer", strings.Join(joinedAuthors, ", "))
tag("Summary", item.Description.String)
publisher := ""
if len(item.Publishers) > 0 {
publisher = item.Publishers[0]
} else if item.Publisher.Valid {
publisher = item.Publisher.String
}
tag("Publisher", strings.TrimSpace(publisher))
if item.DatePublished.Valid {
t := item.DatePublished.Time
tagInt("Year", t.Year())
tagInt("Month", int(t.Month()))
tagInt("Day", t.Day())
}
tag("LanguageISO", strings.TrimSpace(item.Language.String))
if item.WebUrl.Valid {
tag("Web", strings.TrimSpace(item.WebUrl.String))
}
genre := strings.TrimSpace(item.Genre.String)
if genre == "" && len(item.Tags) > 0 {
genre = strings.TrimSpace(item.Tags[0])
}
tag("Genre", genre)
if len(item.Tags) > 0 {
cleanTags := make([]string, 0, len(item.Tags))
for _, t := range item.Tags {
t = strings.TrimSpace(t)
if t != "" {
cleanTags = append(cleanTags, t)
}
}
tag("Tags", strings.Join(cleanTags, ", "))
}
b.WriteString("</ComicInfo>\n")
return b.String()
}
// writeCBZComicInfo injects (or replaces) ComicInfo.xml inside the CBZ
// — same atomic zip rewrite as the EPUB path, every other entry copied
// byte-identical.
func (w *MetadataWriter) writeCBZComicInfo(absPath string, item *database.MediaItems) (string, error) {
reader, err := zip.OpenReader(absPath)
if err != nil {
return "", fmt.Errorf("failed to open CBZ: %w", err)
}
defer reader.Close()
newXML := buildComicInfoXML(item)
tmpPath := absPath + ".tmp-write"
if err := rewriteZipArchive(reader.File, "ComicInfo.xml", []byte(newXML), tmpPath, "ComicInfo.xml", []byte(newXML)); err != nil {
os.Remove(tmpPath)
return "", err
}
if err := os.Rename(tmpPath, absPath); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("failed to replace CBZ: %w", err)
}
return absPath, nil
}
// ---- OPF generation (mirrors parseOPFContent's read set) ----
// escXML escapes the five XML entities for element and attribute text.
func escXML(s string) string {
return strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
).Replace(s)
}
// buildSidecarOPFXML renders a complete Calibre-compatible package
// document for a sidecar.
func buildSidecarOPFXML(item *database.MediaItems) string {
var b strings.Builder
b.WriteString(xml.Header)
b.WriteString(`<package version="2.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookhoard-uuid">` + "\n")
b.WriteString(`<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">` + "\n")
b.WriteString(buildOPFMetadataInner(item))
b.WriteString("</metadata>\n</package>\n")
return b.String()
}
// buildOPFMetadataInner renders the dc:* elements for the item's
// current values — exactly the fields parseOPFContent reads back
// (title/subtitle via title-type, creators, subjects, description,
// publisher, date, language, isbn identifier, calibre:series metas).
// Empty DB values simply omit their element: a sidecar absent-field
// means "no data", never "delete this from the file".
func buildOPFMetadataInner(item *database.MediaItems) string {
var b strings.Builder
title := strings.TrimSpace(item.Title)
if title != "" {
b.WriteString("\t<dc:title id=\"tmain\">" + escXML(title) + "</dc:title>\n")
b.WriteString("\t<meta refines=\"#tmain\" property=\"title-type\">main</meta>\n")
}
if item.Subtitle.Valid && strings.TrimSpace(item.Subtitle.String) != "" {
b.WriteString("\t<dc:title id=\"tsub\">" + escXML(strings.TrimSpace(item.Subtitle.String)) + "</dc:title>\n")
b.WriteString("\t<meta refines=\"#tsub\" property=\"title-type\">subtitle</meta>\n")
}
for _, a := range item.Authors {
a = strings.TrimSpace(a)
if a == "" {
continue
}
b.WriteString("\t<dc:creator opf:role=\"aut\">" + escXML(a) + "</dc:creator>\n")
}
if len(item.Authors) == 0 && item.Author.Valid && strings.TrimSpace(item.Author.String) != "" {
b.WriteString("\t<dc:creator opf:role=\"aut\">" + escXML(strings.TrimSpace(item.Author.String)) + "</dc:creator>\n")
}
if item.Description.Valid && strings.TrimSpace(item.Description.String) != "" {
b.WriteString("\t<dc:description>" + escXML(item.Description.String) + "</dc:description>\n")
}
for _, p := range item.Publishers {
p = strings.TrimSpace(p)
if p == "" {
continue
}
b.WriteString("\t<dc:publisher>" + escXML(p) + "</dc:publisher>\n")
break // parser reads the first
}
if item.DatePublished.Valid {
b.WriteString("\t<dc:date>" + item.DatePublished.Time.Format("2006-01-02") + "</dc:date>\n")
}
if item.Language.Valid && strings.TrimSpace(item.Language.String) != "" {
b.WriteString("\t<dc:language>" + escXML(strings.TrimSpace(item.Language.String)) + "</dc:language>\n")
}
if item.Isbn.Valid && strings.TrimSpace(item.Isbn.String) != "" {
b.WriteString("\t<dc:identifier opf:scheme=\"ISBN\">" + escXML(strings.TrimSpace(item.Isbn.String)) + "</dc:identifier>\n")
}
// The package's unique-identifier needs a matching element: the
// scan's OPF uuid when known, else the item id (the parser files
// urn:uuid under hash info, never metadata).
uid := ""
if item.OpfUuid.Valid {
uid = strings.TrimSpace(item.OpfUuid.String)
}
if uid == "" && item.ID.Valid {
uid = uuid.UUID(item.ID.Bytes).String()
}
if uid != "" {
b.WriteString("\t<dc:identifier id=\"bookhoard-uuid\">urn:uuid:" + escXML(uid) + "</dc:identifier>\n")
}
for _, t := range item.Tags {
t = strings.TrimSpace(t)
if t == "" {
continue
}
b.WriteString("\t<dc:subject>" + escXML(t) + "</dc:subject>\n")
}
if item.Series.Valid && strings.TrimSpace(item.Series.String) != "" {
b.WriteString("\t<meta name=\"calibre:series\" content=\"" + escXML(item.Series.String) + "\"/>\n")
if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 {
b.WriteString("\t<meta name=\"calibre:series_index\" content=\"" + strconvFormatFloat(item.SeriesNumber.Float64) + "\"/>\n")
}
}
return b.String()
}
// metadataFieldNames lists the DB fields a write mirrors — response
// contract for the UI ("what got written").
func metadataFieldNames(item *database.MediaItems) []string {
fields := []string{"title"}
if item.Subtitle.Valid && item.Subtitle.String != "" {
fields = append(fields, "subtitle")
}
if (len(item.Authors) > 0) || (item.Author.Valid && item.Author.String != "") {
fields = append(fields, "authors")
}
if item.Description.Valid && item.Description.String != "" {
fields = append(fields, "description")
}
if len(item.Publishers) > 0 || (item.Publisher.Valid && item.Publisher.String != "") {
fields = append(fields, "publisher")
}
if item.DatePublished.Valid {
fields = append(fields, "date_published")
}
if item.Language.Valid && item.Language.String != "" {
fields = append(fields, "language")
}
if item.Isbn.Valid && item.Isbn.String != "" {
fields = append(fields, "isbn")
}
if len(item.Tags) > 0 {
fields = append(fields, "tags")
}
if item.Series.Valid && item.Series.String != "" {
fields = append(fields, "series")
}
return fields
}
// strconvFormatFloat renders series indexes without trailing noise
// (3 → "3", 3.5 → "3.5").
func strconvFormatFloat(f float64) string {
return fmt.Sprintf("%g", f)
}
+352
View File
@@ -0,0 +1,352 @@
package services
import (
"archive/zip"
"bytes"
"encoding/xml"
"io"
"os"
"path/filepath"
"testing"
"time"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Unit tests for the metadata writer's pure parts (2026-10-27): OPF
// generation round-trips through the scanner's OWN parser
// (parseOPFContent — write side and read side can never disagree), the
// EPUB splice preserves manifest/spine, and the zip rewrite keeps
// mimetype first + stored. The ffmpeg audio writer (5b) will extend
// this file.
func fixtureItem() *database.MediaItems {
pubDate, _ := time.Parse("2006-01-02", "1853-07-14")
return &database.MediaItems{
Title: `Progress & "Quotes" <etc>`,
Subtitle: pgtype.Text{String: "A Subtitle", Valid: true},
Authors: []string{"Ada Lovelace", "Grace Hopper"},
Description: pgtype.Text{
String: "A <b>description</b> with entities & such", Valid: true,
},
Publishers: []string{"Analytical Engine Press"},
DatePublished: pgtype.Date{Time: pubDate, Valid: true},
Language: pgtype.Text{String: "en", Valid: true},
Isbn: pgtype.Text{String: "978-0-13-235088-4", Valid: true},
Tags: []string{"Science", "Computing"},
Series: pgtype.Text{String: "Scientific Memoirs", Valid: true},
SeriesNumber: pgtype.Float8{Float64: 3, Valid: true},
}
}
// buildTestEPUB creates a minimal EPUB (mimetype + container + OPF with
// manifest/spine) in memory.
func buildTestEPUB(t *testing.T, opfContent string) []*zip.File {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
mimetype, _ := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
_, _ = mimetype.Write([]byte("application/epub+zip"))
container, _ := w.Create("META-INF/container.xml")
_, _ = container.Write([]byte(`<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`))
opf, _ := w.Create("OEBPS/content.opf")
_, _ = opf.Write([]byte(opfContent))
chapter, _ := w.Create("OEBPS/chapter1.xhtml")
_, _ = chapter.Write([]byte("<html><body>Chapter</body></html>"))
require.NoError(t, w.Close())
reader, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
require.NoError(t, err)
return reader.File
}
const spliceTestOPF = `<?xml version="1.0" encoding="utf-8"?>
<package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title id="t1">Old Title</dc:title>
<dc:creator>Old Author</dc:creator>
<dc:identifier id="pub-id">urn:uuid:1234</dc:identifier>
</metadata>
<manifest>
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine>
<itemref idref="ch1"/>
</spine>
</package>
`
func TestMetadataWriteTargetFor(t *testing.T) {
assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".epub"))
assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".KEPUB"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".pdf"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(""))
}
func TestBuildOPFMetadataInnerRoundTrips(t *testing.T) {
item := fixtureItem()
xmlInner := buildOPFMetadataInner(item)
// Wrap in a full package and parse with the SCANNER's parser: the
// write must survive the read.
doc := `<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">` +
xmlInner + `</metadata></package>`
md, err := parseOPFContent([]byte(doc))
require.NoError(t, err)
assert.Equal(t, "Progress & \"Quotes\" <etc>: A Subtitle", md.Title, "main title + calibre-style subtitle join")
assert.Equal(t, "Ada Lovelace", md.Author, "first creator")
assert.Equal(t, "A <b>description</b> with entities & such", md.Description, "entities unescaped on read")
assert.Equal(t, "Analytical Engine Press", md.Publisher)
assert.Equal(t, "en", md.Language)
assert.Equal(t, "9780132350884", md.ISBN, "normalized on read")
assert.Equal(t, "Scientific Memoirs", md.Series)
assert.Equal(t, float64(3), md.SeriesNumber)
assert.ElementsMatch(t, []string{"Science", "Computing"}, md.Tags)
require.NotNil(t, md.PublishDate)
assert.Equal(t, "1853-07-14", md.PublishDate.Format("2006-01-02"))
}
func TestBuildOPFMetadataInnerOmitsEmptyFields(t *testing.T) {
item := &database.MediaItems{Title: "Only A Title"}
xmlInner := buildOPFMetadataInner(item)
assert.NotContains(t, xmlInner, "dc:creator")
assert.NotContains(t, xmlInner, "dc:description")
assert.NotContains(t, xmlInner, "calibre:series")
assert.NotContains(t, xmlInner, "dc:title id=\"tsub\"", "no subtitle element when unset")
// Singular-author fallback when the authors array is empty.
item = &database.MediaItems{
Title: "Fallback",
Author: pgtype.Text{String: "Solo Author", Valid: true},
}
assert.Contains(t, buildOPFMetadataInner(item), "Solo Author")
}
func TestSpliceOPFMetadataPreservesStructure(t *testing.T) {
entries := buildTestEPUB(t, spliceTestOPF)
item := fixtureItem()
newOPF, err := spliceOPFMetadata(entries, "OEBPS/content.opf", item)
require.NoError(t, err)
// The old metadata values are gone…
out := string(newOPF)
assert.NotContains(t, out, "Old Title")
assert.NotContains(t, out, "Old Author")
// …the new values are in…
assert.Contains(t, out, `Progress &amp; &quot;Quotes&quot; &lt;etc&gt;`)
assert.Contains(t, out, "Ada Lovelace")
// …and manifest/spine/identifier survive untouched.
assert.Contains(t, out, `<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>`)
assert.Contains(t, out, `<itemref idref="ch1"/>`)
assert.Contains(t, out, `urn:uuid:1234`)
assert.Contains(t, out, `unique-identifier="pub-id"`, "original opening tag preserved")
// The spliced OPF parses back with the scanner's parser.
md, err := parseOPFContent(newOPF)
require.NoError(t, err)
assert.Equal(t, item.Title+": A Subtitle", md.Title)
}
func TestRewriteEPUBZipKeepsMimetypeFirstAndStored(t *testing.T) {
entries := buildTestEPUB(t, spliceTestOPF)
item := fixtureItem()
newOPF, err := spliceOPFMetadata(entries, "OEBPS/content.opf", item)
require.NoError(t, err)
tmp := filepath.Join(t.TempDir(), "book.epub")
require.NoError(t, rewriteEPUBZip(entries, "OEBPS/content.opf", newOPF, tmp))
reader, err := zip.OpenReader(tmp)
require.NoError(t, err)
defer reader.Close()
require.NotEmpty(t, reader.File)
assert.Equal(t, "mimetype", reader.File[0].Name, "mimetype must be the first entry")
assert.Equal(t, zip.Store, reader.File[0].FileHeader.Method, "mimetype must be uncompressed")
var opfEntry *zip.File
names := []string{}
for _, f := range reader.File {
names = append(names, f.Name)
if f.Name == "OEBPS/content.opf" {
opfEntry = f
}
}
assert.ElementsMatch(t, []string{"mimetype", "META-INF/container.xml", "OEBPS/content.opf", "OEBPS/chapter1.xhtml"}, names)
require.NotNil(t, opfEntry)
rc, err := opfEntry.Open()
require.NoError(t, err)
content, err := io.ReadAll(rc)
require.NoError(t, rc.Close())
require.NoError(t, err)
assert.Contains(t, string(content), "Ada Lovelace")
}
func TestAtomicWriteReplacesContent(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "metadata.opf")
require.NoError(t, os.WriteFile(target, []byte("old"), 0o644))
require.NoError(t, atomicWrite(target, []byte("new content")))
got, err := os.ReadFile(target)
require.NoError(t, err)
assert.Equal(t, "new content", string(got))
// No temp litter left behind.
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Len(t, entries, 1)
}
func TestAudioTagArgsMapsRoundTripKeys(t *testing.T) {
// Every key here must have a reader in applyAudioTagFallbacks —
// that's the round-trip contract with the scanner.
item := &database.MediaItems{
Title: "The Book",
Subtitle: pgtype.Text{String: "The Sub", Valid: true},
Authors: []string{"Ada Lovelace", "Grace Hopper"},
Narrators: []string{"Simon Vance"},
Series: pgtype.Text{String: "Memoirs", Valid: true},
SeriesNumber: pgtype.Float8{Float64: 3.5, Valid: true},
DatePublished: pgtype.Date{
Time: dateOf("1953-04-16"),
Valid: true,
},
Tags: []string{"Science"},
}
got := map[string]string{}
for _, kv := range audioTagArgs(item, ".mp3") {
got[kv[0]] = kv[1]
}
assert.Equal(t, "The Book", got["title"])
assert.Equal(t, "The Book", got["album"], "the scanner's album rung names the book")
assert.Equal(t, "The Sub", got["subtitle"])
assert.Equal(t, "Ada Lovelace", got["artist"], "first author")
assert.Equal(t, "Ada Lovelace & Grace Hopper", got["album_artist"])
assert.Equal(t, "Simon Vance", got["composer"], "narrators ride composer (Audible convention)")
assert.Equal(t, "Memoirs", got["grouping"])
assert.Equal(t, "3.5", got["----:com.apple.itunes:series-part"])
assert.Equal(t, "1953-04-16", got["date"])
assert.Equal(t, "Science", got["genre"])
// Empty fields emit nothing (a tag write must not blank a book).
empty := audioTagArgs(&database.MediaItems{Title: "T"}, ".mp3")
keys := map[string]bool{}
for _, kv := range empty {
keys[kv[0]] = true
}
assert.True(t, keys["title"])
assert.False(t, keys["subtitle"])
assert.False(t, keys["artist"])
assert.False(t, keys["composer"])
assert.False(t, keys["grouping"])
assert.False(t, keys["genre"])
// M4B drops subtitle keys in this ffmpeg build — never emitted.
m4b := audioTagArgs(item, ".m4b")
for _, kv := range m4b {
assert.NotEqual(t, "subtitle", kv[0], "mp4 muxer drops subtitle; don't write it")
}
}
// Single-file audio dispatch (5b): the four containers the ffmpeg tag
// writer claims; folder/multi-file audio stays on the sidecar path.
func TestAudioTagExts(t *testing.T) {
for _, ext := range []string{".mp3", ".flac", ".m4a", ".m4b"} {
assert.Equal(t, MetadataTargetAudioTags, MetadataWriteTargetFor(ext), ext)
}
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".pdf"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".opus"))
}
// CBZ dispatch (5c): ComicInfo injection claims CBZ; CBR/CB7 can't be
// rewritten in place and stay on the sidecar path.
func TestComicInfoTargetFor(t *testing.T) {
assert.Equal(t, MetadataTargetComicInfo, MetadataWriteTargetFor(".cbz"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cbr"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cb7"))
}
// buildComicInfoXML must round-trip through the scanner's OWN ComicInfo
// struct (xml.Unmarshal — same parser extractMetadataFromArchive uses).
func TestBuildComicInfoXMLRoundTrips(t *testing.T) {
pubDate, _ := time.Parse("2006-01-02", "1987-03-10")
item := &database.MediaItems{
Title: "The Sad Comic <#1>",
Series: pgtype.Text{String: "Watchmen", Valid: true},
SeriesNumber: pgtype.Float8{Float64: 3, Valid: true},
SeriesCount: pgtype.Int4{Int32: 12, Valid: true},
Authors: []string{"Alan Moore", "Dave Gibbons"},
Description: pgtype.Text{String: "Description & <entities>", Valid: true},
Publishers: []string{"DC Comics"},
DatePublished: pgtype.Date{
Time: pubDate,
Valid: true,
},
Language: pgtype.Text{String: "en", Valid: true},
WebUrl: pgtype.Text{String: "https://example.com/watchmen", Valid: true},
Tags: []string{"Superhero", "Drama"},
}
parsed := ComicInfo{}
require.NoError(t, xml.Unmarshal([]byte(buildComicInfoXML(item)), &parsed))
assert.Equal(t, "The Sad Comic <#1>", parsed.Title)
assert.Equal(t, "Watchmen", parsed.Series)
assert.Equal(t, 3, parsed.Number)
assert.Equal(t, 12, parsed.Count)
assert.Equal(t, "Alan Moore, Dave Gibbons", parsed.Writer)
assert.Equal(t, "Description & <entities>", parsed.Summary)
assert.Equal(t, "DC Comics", parsed.Publisher)
assert.Equal(t, 1987, parsed.Year)
assert.Equal(t, 3, parsed.Month)
assert.Equal(t, 10, parsed.Day)
assert.Equal(t, "en", parsed.LanguageISO)
assert.Equal(t, "https://example.com/watchmen", parsed.Web)
assert.Equal(t, "Superhero", parsed.Genre, "first tag doubles as genre")
assert.Equal(t, "Superhero, Drama", parsed.Tags)
}
func TestBuildComicInfoXMLSkipsFractionalNumber(t *testing.T) {
// Number is an integer field; a fractional series number must not
// round-trip truncated (a lie).
item := &database.MediaItems{
Title: "X",
Series: pgtype.Text{String: "S", Valid: true},
SeriesNumber: pgtype.Float8{Float64: 3.5, Valid: true},
}
parsed := ComicInfo{}
require.NoError(t, xml.Unmarshal([]byte(buildComicInfoXML(item)), &parsed))
assert.Equal(t, 0, parsed.Number, "fractional series number omitted, not truncated")
assert.NotContains(t, buildComicInfoXML(item), "<Number>")
}
// The demoted-genre contract in full (885d6140): a format word only
// sinks to tags-only when it's ALONE. A blob where ANY candidate maps
// to a shelf still wins the genre column; the demoted word never does.
func TestPickSmartGenreMixedBlob(t *testing.T) {
genre, all := pickSmartGenre([]string{"Audiobook, Science Fiction, Unabridged"})
assert.Equal(t, "science fiction", genre, "a recognized shelf wins the column")
assert.Contains(t, all, "Audiobook", "the demoted word is still a tag candidate")
assert.Contains(t, all, "Unabridged")
// Format words alone → column empty, everything tags-only.
genre, all = pickSmartGenre([]string{"Audiobook, Unabridged"})
assert.Empty(t, genre, "demoted words alone never fill the genre column")
assert.Contains(t, all, "Audiobook")
assert.Contains(t, all, "Unabridged")
}
func dateOf(s string) time.Time {
tm, _ := time.Parse("2006-01-02", s)
return tm
}
+37
View File
@@ -320,3 +320,40 @@ func TestSeriesNumberFloatOrderingValues(t *testing.T) {
t.Fatal("1 must sort before 1.5")
}
}
// ResolveSeriesSort composes the toolbar's base key + direction into
// the registry key the SQL cascade implements. "recent" is both a base
// key AND a full registry key (meaning desc) — the composition must
// run BEFORE the full-key passthrough, or Date Added can never sort
// ascending (2026-10-27 regression the direction toggle exposed).
func TestResolveSeriesSortComposition(t *testing.T) {
cases := []struct {
sort, dir string
wantKey, wantD string
}{
// THE regression: Date Added + toggle up.
{"recent", "asc", "recent_asc", "asc"},
{"recent", "", "recent", "desc"},
{"recent", "desc", "recent", "desc"},
{"recent", "bogus", "recent", "desc"},
// Full keys (back-compat URLs / JSON API) win as-is.
{"name_desc", "", "name_desc", "desc"},
{"name_asc", "desc", "name_asc", "asc"},
// Base keys compose; default direction applies when dir absent.
{"name", "", "name_asc", "asc"},
{"name", "desc", "name_desc", "desc"},
{"books", "", "books_desc", "desc"},
{"books", "asc", "books_asc", "asc"},
{"last_read", "asc", "last_read_asc", "asc"},
// Unknown/empty → default.
{"", "", "recent", "desc"},
{"bogus", "asc", "recent", "desc"},
}
for _, tc := range cases {
key, dir := ResolveSeriesSort(tc.sort, tc.dir)
if key != tc.wantKey || dir != tc.wantD {
t.Errorf("ResolveSeriesSort(%q, %q) = (%q, %q), want (%q, %q)",
tc.sort, tc.dir, key, dir, tc.wantKey, tc.wantD)
}
}
}
+172 -3
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
@@ -16,6 +17,143 @@ type SeriesInfo struct {
TotalInSeries int
CoverPaths []string
LastEntryAt string
// Viewer-scoped progress aggregates (requesting user): books
// finished (percentage >= 0.999) and the series' most recent read.
ReadCount int64
LastReadAt string
}
// SeriesPageOptions carries the series page's sort/filter state. Zero
// values mean defaults (recent, no filters). Extensible by design:
// adding a sort = one entry in seriesSortKeys + a matching CASE arm in
// queries.sql (GetDistinctSeries) + one <option> in templates/
// series.templ; adding a filter = a param on this struct threaded into
// the query's WHERE/HAVING. Unknown values resolve to defaults, so a
// stale URL or hand-rolled API call can never reach the SQL with an
// unhandled key.
type SeriesPageOptions struct {
Sort string // base toolbar key or full registry key
Dir string // asc | desc ("" = the base key's default)
Query string // multi-field: series name, author, or book title
Author string // matches when ANY book's author matches
Status string // "" | unstarted | reading | finished
ViewerUserID pgtype.UUID
}
// seriesSortKeys is the registry of sort= values the series page
// understands.
var seriesSortKeys = map[string]bool{
"recent": true, // default: newest series first
"recent_asc": true,
"name_asc": true,
"name_desc": true,
"author_asc": true,
"author_desc": true,
"books_asc": true,
"books_desc": true,
"progress_asc": true,
"progress_desc": true,
"last_read_asc": true,
"last_read_desc": true,
}
// seriesStatusKeys is the registry of status= values. The three states
// are mutually exclusive and exhaustive: a series is finished when
// every owned book is, reading when any book sits between 0 and the
// finish threshold, unstarted when no book has progress > 0.
var seriesStatusKeys = map[string]bool{
"unstarted": true,
"reading": true,
"finished": true,
}
// seriesBaseSorts is the toolbar's base sort keys (the <select>) with
// the direction each one STARTS in (the asc/desc toggle's initial
// state). Composed with ResolveSeriesSort into the full registry keys
// the SQL cascade implements.
var seriesBaseSorts = map[string]string{
"recent": "desc",
"name": "asc",
"author": "asc",
"books": "desc",
"progress": "desc",
"last_read": "desc",
}
// ResolveSeriesSort maps a sort value (either a FULL registry key like
// "name_desc" — back-compat with old URLs and the JSON API — or a BASE
// toolbar key composed with a direction) to the registry key the SQL
// cascade implements, plus the EFFECTIVE direction (for the toggle's
// arrow). Unknown values resolve to the default.
func ResolveSeriesSort(sort, dir string) (string, string) {
sort = strings.TrimSpace(strings.ToLower(sort))
dir = strings.ToLower(strings.TrimSpace(dir))
if dir != "asc" && dir != "desc" {
dir = ""
}
// "recent" is BOTH a base toolbar key and a registry key (it means
// desc). Handle it before the full-key check or dir=asc is silently
// swallowed and the direction toggle can never flip Date Added.
if sort == "recent" {
if dir == "asc" {
return "recent_asc", "asc"
}
return "recent", "desc"
}
// Full registry key straight from the URL wins as-is.
if seriesSortKeys[sort] {
return sort, seriesDirOf(sort)
}
base := sort
if base == "" {
base = "recent"
}
defaultDir, known := seriesBaseSorts[base]
if !known {
// Unknown base: the whole thing falls to the default — an
// unhandled sort must not sneak a direction past the registry.
return "recent", "desc"
}
if dir == "" {
dir = defaultDir
}
if base == "recent" && dir == "desc" {
return "recent", "desc"
}
return base + "_" + dir, dir
}
// seriesDirOf derives the display direction of a full registry key.
func seriesDirOf(key string) string {
switch {
case strings.HasSuffix(key, "_asc"):
return "asc"
case strings.HasSuffix(key, "_desc"):
return "desc"
case key == "recent":
return "desc"
default:
if d, ok := seriesBaseSorts[key]; ok {
return d
}
return "desc"
}
}
// SeriesSortBase strips the direction suffix from a full registry key
// ("name_desc" → "name") for the toolbar's base-key <select>.
func SeriesSortBase(key string) string {
key = strings.TrimSuffix(strings.TrimSuffix(key, "_asc"), "_desc")
if _, ok := seriesBaseSorts[key]; ok {
return key
}
return "recent"
}
// nargText makes an optional text param: empty string → SQL NULL so
// the query's IS NULL guard skips the filter.
func nargText(v string) pgtype.Text {
return pgtype.Text{String: v, Valid: v != ""}
}
type SeriesService struct {
@@ -31,18 +169,35 @@ func NewSeriesService(db *database.Queries) *SeriesService {
// Pass libraryID for a single-library page, or leave it invalid and pass
// visibleLibraryIDs for an across-my-libraries page. Both filters compose:
// the handler validates any explicit library_id against the visible set.
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]SeriesInfo, int, error) {
totalCount, err := s.db.GetDistinctSeriesCount(ctx, database.GetDistinctSeriesCountParams{
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts SeriesPageOptions) ([]SeriesInfo, int, error) {
sortKey, _ := ResolveSeriesSort(opts.Sort, opts.Dir)
sort := nargText(sortKey)
status := nargText("")
if seriesStatusKeys[opts.Status] {
status = nargText(opts.Status)
}
countParams := database.GetDistinctSeriesCountParams{
ViewerUserID: opts.ViewerUserID,
LibraryID: libraryID,
VisibleLibraryIds: visibleLibraryIDs,
})
SeriesQuery: nargText(opts.Query),
AuthorQuery: nargText(opts.Author),
Status: status,
}
totalCount, err := s.db.GetDistinctSeriesCount(ctx, countParams)
if err != nil {
return nil, 0, err
}
rows, err := s.db.GetDistinctSeries(ctx, database.GetDistinctSeriesParams{
ViewerUserID: opts.ViewerUserID,
LibraryID: libraryID,
VisibleLibraryIds: visibleLibraryIDs,
SeriesQuery: countParams.SeriesQuery,
AuthorQuery: countParams.AuthorQuery,
Status: status,
Sort: sort,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
})
@@ -76,6 +231,18 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
}
}
lastRead := ""
if row.LastReadAt != nil {
switch v := row.LastReadAt.(type) {
case pgtype.Timestamptz:
if v.Valid {
lastRead = v.Time.String()
}
case string:
lastRead = v
}
}
if totalInSeries == 0 || totalInSeries < int(row.BookCount) {
totalInSeries = int(row.BookCount)
}
@@ -87,6 +254,8 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
TotalInSeries: totalInSeries,
CoverPaths: coverPaths,
LastEntryAt: lastEntry,
ReadCount: row.FinishedCount,
LastReadAt: lastRead,
})
}
-1
View File
@@ -7,7 +7,6 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Archived Items - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
+12 -12
View File
@@ -29,7 +29,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Archived Items - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Archived Items - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -79,7 +79,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(retentionDays)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 31, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 30, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -135,7 +135,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 66, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 65, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -153,7 +153,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 68, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 67, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -171,7 +171,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 70, Col: 103}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 69, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -184,7 +184,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.LibraryName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 72, Col: 86}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 71, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -197,7 +197,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 73, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 72, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -215,7 +215,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 78, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 77, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -228,7 +228,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MatchID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 79, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 78, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -241,7 +241,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Merge this archived copy's reading data into the active copy \"" + item.MatchTitle + "\" (" + item.MatchLibraryName + ") and remove the archived entry")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 81, Col: 174}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 80, Col: 174}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
@@ -267,7 +267,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 89, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 88, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -288,7 +288,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 98, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 97, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
+12 -1
View File
@@ -9,7 +9,6 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Library Management - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
@@ -267,6 +266,18 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
for _, folder := range folders {
<div class="flex items-center justify-between gap-2 p-2 rounded-lg" style="background-color: var(--bg-primary);">
<code class="text-xs flex-1 truncate" style="color: var(--text-primary)">{ folder.FolderPath }</code>
<button
class="icon-btn h-7 w-7 shrink-0"
hx-post={ "/api/libraries/" + libraryID + "/scan" }
hx-vals={ hxValsFolderScan(folder.FolderPath) }
hx-target="#scan-indicator"
hx-swap="innerHTML"
hx-confirm="Scan this folder?"
title="Scan this folder"
aria-label="Scan this folder"
>
@Icon("refresh", "h-3.5 w-3.5")
</button>
<button
class="icon-btn h-7 w-7 shrink-0"
hx-delete={ "/admin/library/" + libraryID + "/folders" }
+236 -202
View File
@@ -31,7 +31,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Library Management - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Library Management - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -89,7 +89,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(archivedCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 45, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 44, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -220,7 +220,7 @@ func LibraryTypeOptions(types []database.LibraryTypes) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 186, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 185, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -233,7 +233,7 @@ func LibraryTypeOptions(types []database.LibraryTypes) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(LibraryTypeDisplayName(t.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 186, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 185, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -303,7 +303,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 210, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 209, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -321,7 +321,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 212, Col: 95}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 211, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -343,7 +343,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 217, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 216, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -365,7 +365,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 222, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 221, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -383,7 +383,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 228, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 227, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -404,7 +404,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 238, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 237, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -425,7 +425,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 247, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 246, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -500,20 +500,20 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 269, Col: 99}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 268, Col: 99}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</code> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-delete=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</code> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/scan")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 272, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 271, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
@@ -524,28 +524,62 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderScan(folder.FolderPath))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 273, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 272, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-target=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-target=\"#scan-indicator\" hx-swap=\"innerHTML\" hx-confirm=\"Scan this folder?\" title=\"Scan this folder\" aria-label=\"Scan this folder\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</button> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 274, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 283, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" hx-swap=\"innerHTML\" hx-confirm=\"Remove this folder from the library?\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 285, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" hx-swap=\"innerHTML\" hx-confirm=\"Remove this folder from the library?\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -553,82 +587,82 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<form class=\"flex gap-2\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 124}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" hx-swap=\"innerHTML\"><input type=\"text\" name=\"folder_path\" id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<form class=\"flex gap-2\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 288, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 295, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" placeholder=\"/path/to/books\" class=\"input flex-1\" required> <button type=\"button\" data-library-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 295, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 295, Col: 124}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" data-target-input=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" hx-swap=\"innerHTML\"><input type=\"text\" name=\"folder_path\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 296, Col: 52}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 299, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" @click=\"openFolderBrowser($el.dataset.targetInput, $el.dataset.libraryId)\" class=\"btn btn-secondary text-sm shrink-0\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" placeholder=\"/path/to/books\" class=\"input flex-1\" required> <button type=\"button\" data-library-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 306, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" data-target-input=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 307, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" @click=\"openFolderBrowser($el.dataset.targetInput, $el.dataset.libraryId)\" class=\"btn btn-secondary text-sm shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -636,7 +670,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Browse</button> <button type=\"submit\" class=\"btn btn-primary text-sm shrink-0\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "Browse</button> <button type=\"submit\" class=\"btn btn-primary text-sm shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -644,12 +678,12 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "Add</button></form></div><!-- User Visibility -->")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "Add</button></form></div><!-- User Visibility -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(users) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<div><div class=\"flex items-center gap-2 mb-3\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<div><div class=\"flex items-center gap-2 mb-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -657,115 +691,115 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<h4 class=\"text-sm font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">User Access</h4></div><div class=\"space-y-1\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<h4 class=\"text-sm font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">User Access</h4></div><div class=\"space-y-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, u := range users {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<label class=\"flex items-center gap-3 cursor-pointer p-2 rounded-lg transition-colors hover:bg-surface-hover\"><input type=\"checkbox\" class=\"w-4 h-4 rounded\" name=\"is_visible\" value=\"true\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<label class=\"flex items-center gap-3 cursor-pointer p-2 rounded-lg transition-colors hover:bg-surface-hover\"><input type=\"checkbox\" class=\"w-4 h-4 rounded\" name=\"is_visible\" value=\"true\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if isUserVisible(u.ID, visibility) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " checked")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 325, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 326, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" hx-trigger=\"change\" hx-target=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 328, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 336, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" hx-swap=\"innerHTML\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 331, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 337, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" hx-trigger=\"change\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 332, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 339, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span></label>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" hx-swap=\"innerHTML\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 342, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 343, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span></label>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<!-- Processing Issues -->")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<!-- Processing Issues -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if issueCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<div><a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 templ.SafeURL
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
var templ_7745c5c3_Var33 templ.SafeURL
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 341, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 352, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -774,25 +808,25 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
return templ_7745c5c3_Err
}
if issueCount == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span>1 processing issue</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span>1 processing issue</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 346, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 357, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " processing issues</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " processing issues</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -801,51 +835,51 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</a></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<!-- Actions --><div class=\"flex gap-2 pt-2 border-t\" style=\"border-color: var(--border);\"><button data-edit-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 355, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" data-edit-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 356, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" data-edit-desc=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<!-- Actions --><div class=\"flex gap-2 pt-2 border-t\" style=\"border-color: var(--border);\"><button data-edit-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 357, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 366, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" @click=\"openEditModal($el.dataset.editId, $el.dataset.editName, $el.dataset.editDesc)\" class=\"btn btn-secondary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" data-edit-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 367, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" data-edit-desc=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 368, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" @click=\"openEditModal($el.dataset.editId, $el.dataset.editName, $el.dataset.editDesc)\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -853,33 +887,33 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "Edit Details</button> <button data-delete-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Edit Details</button> <button data-delete-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 365, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 376, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" data-delete-name=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" data-delete-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 366, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 377, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" @click=\"openDeleteModal($el.dataset.deleteId, $el.dataset.deleteName)\" class=\"btn btn-danger text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" @click=\"openDeleteModal($el.dataset.deleteId, $el.dataset.deleteName)\" class=\"btn btn-danger text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -887,7 +921,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Delete Library</button></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "Delete Library</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -911,12 +945,12 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var38 := templ.GetChildren(ctx)
if templ_7745c5c3_Var38 == nil {
templ_7745c5c3_Var38 = templ.NopComponent
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
if templ_7745c5c3_Var40 == nil {
templ_7745c5c3_Var40 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<div><div class=\"flex items-center gap-2 mb-3 p-2 rounded-lg\" style=\"background-color: var(--bg-primary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<div><div class=\"flex items-center gap-2 mb-3 p-2 rounded-lg\" style=\"background-color: var(--bg-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -924,38 +958,38 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<code class=\"text-xs flex-1 truncate\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<code class=\"text-xs flex-1 truncate\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 381, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 392, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</code></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</code></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if parentPath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 387, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 398, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -963,30 +997,30 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<span>..</span></button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<span>..</span></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<div class=\"space-y-1 max-h-64 overflow-y-auto\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<div class=\"space-y-1 max-h-64 overflow-y-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, entry := range entries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\" hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 400, Col: 118}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 411, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -994,51 +1028,51 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"truncate\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<span class=\"truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 405, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 416, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</span></button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</span></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</div><div class=\"mt-4 flex justify-end\"><button type=\"button\" data-target-input=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</div><div class=\"mt-4 flex justify-end\"><button type=\"button\" data-target-input=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 412, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 423, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" data-current-path=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" data-current-path=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 413, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 424, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" @click=\"pickBrowsePath($event)\" class=\"btn btn-primary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\" @click=\"pickBrowsePath($event)\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1046,7 +1080,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "Select This Folder</button></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "Select This Folder</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+123 -82
View File
@@ -12,7 +12,13 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
</head>
<body class={ "theme-" + user.Theme }>
@Header(user, "/admin/library")
<main class="p-8">
<!-- The retry queue component: per-issue Retry buttons and Retry all
run each issue through the individual scanner sequentially (one
book in flight at a time). The whole variable section below is a
templ partial (#issues-content) so the client can re-render
server truth after the queue drains — htmx.ajax swap, never
JSON-into-innerHTML. -->
<main class="p-8" x-data="processingIssues" data-library-id={ libraryID }>
<div class="mx-auto max-w-4xl">
<div class="mb-8">
<div>
@@ -25,91 +31,126 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
<p class="text-sm" style="color: var(--text-secondary)">Items that couldn't be processed in this library</p>
</div>
</div>
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
if stats.ErrorCount > 0 {
<div class="stat-card" style="border-left: 4px solid var(--status-danger);">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-danger);">
@Icon("x-circle", "h-5 w-5")
<h3 class="text-sm font-semibold uppercase tracking-wide">Errors</h3>
</div>
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.ErrorCount }</p>
</div>
}
if stats.WarningCount > 0 {
<div class="stat-card" style="border-left: 4px solid var(--status-warning);">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-warning);">
@Icon("alert", "h-5 w-5")
<h3 class="text-sm font-semibold uppercase tracking-wide">Warnings</h3>
</div>
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.WarningCount }</p>
</div>
}
if stats.InfoCount > 0 {
<div class="stat-card" style="border-left: 4px solid var(--status-info);">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-info);">
@Icon("info", "h-5 w-5")
<h3 class="text-sm font-semibold uppercase tracking-wide">Info</h3>
</div>
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.InfoCount }</p>
</div>
}
@ProcessingIssuesContent(libraryID, issues, stats)
</div>
</main>
</body>
</html>
}
// ProcessingIssuesContent is the swappable body of the Processing Issues
// page: the severity stat cards (with the Retry-all control on the right
// of the errors row) and the issue card list. Rendered by the page and by
// the /admin/libraries/:id/issues/content partial the retry queue swaps
// in after draining.
templ ProcessingIssuesContent(libraryID string, issues []ProcessingIssueData, stats IssueStats) {
<div id="issues-content">
if len(issues) > 0 {
<div class="flex items-start justify-between gap-6 mb-8">
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 flex-1">
if stats.ErrorCount > 0 {
<div class="stat-card" style="border-left: 4px solid var(--status-danger);">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-danger);">
@Icon("x-circle", "h-5 w-5")
<h3 class="text-sm font-semibold uppercase tracking-wide">Errors</h3>
</div>
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.ErrorCount }</p>
</div>
}
if len(issues) == 0 {
<div class="card p-8 text-center">
<span class="grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("check-circle", "h-6 w-6")
</span>
<p class="text-sm" style="color: var(--text-secondary)">No processing issues found for this library.</p>
if stats.WarningCount > 0 {
<div class="stat-card" style="border-left: 4px solid var(--status-warning);">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-warning);">
@Icon("alert", "h-5 w-5")
<h3 class="text-sm font-semibold uppercase tracking-wide">Warnings</h3>
</div>
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.WarningCount }</p>
</div>
} else {
<!-- Issues List -->
<div class="space-y-4">
for _, issue := range issues {
<div class="card p-6" id={ "issue-" + issue.ID }>
<div class="flex justify-between items-start gap-4 mb-4">
<div class="flex-1 min-w-0">
<h4 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ issue.Title }</h4>
<p class="mb-3 text-sm" style="color: var(--text-secondary)">{ issue.IssueDescription }</p>
<div class="text-sm space-y-1" style="color: var(--text-secondary)">
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Type:</span> { issue.IssueType }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Format:</span> { issue.FormatGroup }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">File:</span> { issue.FilePath }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Library:</span> { issue.LibraryTypeName }</p>
</div>
</div>
<div class="ml-2 shrink-0">
if issue.Severity == "error" {
<span class="badge status-failed">{ issue.Severity }</span>
} else if issue.Severity == "warning" {
<span class="badge status-pending">{ issue.Severity }</span>
} else {
<span class="badge status-processing">{ issue.Severity }</span>
}
</div>
</div>
<div class="flex gap-3 mt-4">
if issue.Severity == "warning" || issue.Severity == "info" {
<button
hx-post={ "/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve" }
hx-target={ "#issue-" + issue.ID }
hx-swap="outerHTML"
hx-confirm="Dismiss this issue?"
class="btn btn-secondary text-sm"
>
@Icon("close", "h-4 w-4")
Dismiss
</button>
}
</div>
</div>
}
}
if stats.InfoCount > 0 {
<div class="stat-card" style="border-left: 4px solid var(--status-info);">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-info);">
@Icon("info", "h-5 w-5")
<h3 class="text-sm font-semibold uppercase tracking-wide">Info</h3>
</div>
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.InfoCount }</p>
</div>
}
</div>
</main>
</body>
</html>
<!-- Retry all: right of the errors row. Queues every issue
currently on the page through the individual scanner,
one at a time — deliberately NOT a whole-library rescan. -->
<button
@click="retryAll()"
:disabled="queueRunning"
class="btn btn-primary shrink-0"
>
<span x-show="!queueRunning" class="inline-flex items-center gap-2">
@Icon("refresh", "h-4 w-4")
<span>Retry all</span>
</span>
<span x-show="queueRunning" x-text="queueRunning ? (queueDone + ' / ' + queueTotal) : ''"></span>
</button>
</div>
}
if len(issues) == 0 {
<div class="card p-8 text-center">
<span class="grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("check-circle", "h-6 w-6")
</span>
<p class="text-sm" style="color: var(--text-secondary)">No processing issues found for this library.</p>
</div>
} else {
<!-- Issues List -->
<div class="space-y-4">
for _, issue := range issues {
<div class="card p-6" id={ "issue-" + issue.ID } data-issue-id={ issue.ID }>
<div class="flex justify-between items-start gap-4 mb-4">
<div class="flex-1 min-w-0">
<h4 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ issue.Title }</h4>
<p class="mb-3 text-sm" style="color: var(--text-secondary)">{ issue.IssueDescription }</p>
<div class="text-sm space-y-1" style="color: var(--text-secondary)">
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Type:</span> { issue.IssueType }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Format:</span> { issue.FormatGroup }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">File:</span> { issue.FilePath }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Library:</span> { issue.LibraryTypeName }</p>
</div>
</div>
<div class="ml-2 shrink-0">
if issue.Severity == "error" {
<span class="badge status-failed">{ issue.Severity }</span>
} else if issue.Severity == "warning" {
<span class="badge status-pending">{ issue.Severity }</span>
} else {
<span class="badge status-processing">{ issue.Severity }</span>
}
</div>
</div>
<div class="flex gap-3 mt-4">
<button
class="btn btn-primary text-sm issue-retry-btn"
@click={ "retryOne(" + JSONArg(issue.ID) + ")" }
:disabled="queueRunning"
>
@Icon("refresh", "h-4 w-4")
Retry
</button>
if issue.Severity == "warning" || issue.Severity == "info" {
<button
hx-post={ "/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve" }
hx-target={ "#issue-" + issue.ID }
hx-swap="outerHTML"
hx-confirm="Dismiss this issue?"
class="btn btn-secondary text-sm"
>
@Icon("close", "h-4 w-4")
Dismiss
</button>
}
</div>
</div>
}
</div>
}
</div>
}
+262 -165
View File
@@ -59,7 +59,20 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<main class=\"p-8\"><div class=\"mx-auto max-w-4xl\"><div class=\"mb-8\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!-- The retry queue component: per-issue Retry buttons and Retry all\n\t\t run each issue through the individual scanner sequentially (one\n\t\t book in flight at a time). The whole variable section below is a\n\t\t templ partial (#issues-content) so the client can re-render\n\t\t server truth after the queue drains — htmx.ajax swap, never\n\t\t JSON-into-innerHTML. --><main class=\"p-8\" x-data=\"processingIssues\" data-library-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 21, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><div class=\"mx-auto max-w-4xl\"><div class=\"mb-8\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -67,17 +80,59 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Processing Issues</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Items that couldn't be processed in this library</p></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Processing Issues</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Items that couldn't be processed in this library</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
templ_7745c5c3_Err = ProcessingIssuesContent(libraryID, issues, stats).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div></main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// ProcessingIssuesContent is the swappable body of the Processing Issues
// page: the severity stat cards (with the Retry-all control on the right
// of the errors row) and the issue card list. Rendered by the page and by
// the /admin/libraries/:id/issues/content partial the retry queue swaps
// in after draining.
func ProcessingIssuesContent(libraryID string, issues []ProcessingIssueData, stats IssueStats) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var5 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div id=\"issues-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(issues) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"flex items-start justify-between gap-6 mb-8\"><!-- Stats Cards --><div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 flex-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if stats.ErrorCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-danger);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-danger);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-danger);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-danger);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -85,43 +140,16 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Errors</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Errors</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 37, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 58, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if stats.WarningCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-warning);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Warnings</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 46, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -130,25 +158,25 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
return templ_7745c5c3_Err
}
}
if stats.InfoCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-info);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-info);\">")
if stats.WarningCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-warning);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("info", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Info</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Warnings</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 55, Col: 91}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 67, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -157,13 +185,48 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
if stats.InfoCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-info);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-info);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("info", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Info</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><!-- Retry all: right of the errors row. Queues every issue\n\t\t\t\t currently on the page through the individual scanner,\n\t\t\t\t one at a time — deliberately NOT a whole-library rescan. --><button @click=\"retryAll()\" :disabled=\"queueRunning\" class=\"btn btn-primary shrink-0\"><span x-show=\"!queueRunning\" class=\"inline-flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span>Retry all</span></span> <span x-show=\"queueRunning\" x-text=\"queueRunning ? (queueDone + ' / ' + queueTotal) : ''\"></span></button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(issues) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"card p-8 text-center\"><span class=\"grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"card p-8 text-center\"><span class=\"grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -171,198 +234,232 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No processing issues found for this library.</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No processing issues found for this library.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<!-- Issues List --> <div class=\"space-y-4\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<!-- Issues List --> <div class=\"space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, issue := range issues {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"card p-6\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 71, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\"><div class=\"flex justify-between items-start gap-4 mb-4\"><div class=\"flex-1 min-w-0\"><h4 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"card p-6\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 75, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 107, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p><div class=\"text-sm space-y-1\" style=\"color: var(--text-secondary)\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Type:</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-issue-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 140}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 107, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Format:</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"><div class=\"flex justify-between items-start gap-4 mb-4\"><div class=\"flex-1 min-w-0\"><h4 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 144}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 110, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">File:</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 139}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 111, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Library:</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p><div class=\"text-sm space-y-1\" style=\"color: var(--text-secondary)\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Type:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 80, Col: 149}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 113, Col: 137}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></div></div><div class=\"ml-2 shrink-0\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Format:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 114, Col: 141}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">File:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 115, Col: 136}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Library:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 116, Col: 146}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</p></div></div><div class=\"ml-2 shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if issue.Severity == "error" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<span class=\"badge status-failed\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 85, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if issue.Severity == "warning" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"badge status-pending\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 87, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"badge status-processing\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 89, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></div><div class=\"flex gap-3 mt-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if issue.Severity == "warning" || issue.Severity == "info" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<button hx-post=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"badge status-failed\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 113}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 121, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" hx-target=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if issue.Severity == "warning" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span class=\"badge status-pending\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 97, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 123, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" hx-swap=\"outerHTML\" hx-confirm=\"Dismiss this issue?\" class=\"btn btn-secondary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span class=\"badge status-processing\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 125, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></div><div class=\"flex gap-3 mt-4\"><button class=\"btn btn-primary text-sm issue-retry-btn\" @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("retryOne(" + JSONArg(issue.ID) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 132, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" :disabled=\"queueRunning\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "Retry</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if issue.Severity == "warning" || issue.Severity == "info" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<button hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 140, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 141, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" hx-swap=\"outerHTML\" hx-confirm=\"Dismiss this issue?\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -370,22 +467,22 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Dismiss</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "Dismiss</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div></main></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+9 -7
View File
@@ -12,7 +12,6 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>System Settings - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
@@ -35,7 +34,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
<span>{ errorMessage }</span>
</div>
}
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
<form id="settings-form" data-settings-save="settings" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
<div class="card p-6">
<div class="flex items-center gap-2 mb-6">
@Icon("globe", "h-5 w-5 shrink-0")
@@ -54,7 +53,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
<p class="text-sm mt-2" style="color: var(--text-secondary)">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p>
</div>
<div class="mt-6 flex justify-end">
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" hx-disabled-elt="this">
@Icon("save", "h-4 w-4")
Save Settings
</button>
@@ -129,9 +128,9 @@ templ ScanSettingsSection(scanSettings ScanSettingsData) {
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Scanning</h3>
</div>
<form
data-settings-save="scan"
hx-put="/admin/settings/scan"
hx-target="#scan-settings-section"
hx-swap="outerHTML"
hx-swap="none"
>
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
@@ -165,7 +164,7 @@ templ ScanSettingsSection(scanSettings ScanSettingsData) {
<p class="text-sm mt-2" style="color: var(--text-secondary)">How often to poll libraries for changes (1–3600 seconds). Default: 60.</p>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" hx-disabled-elt="this">
@Icon("save", "h-4 w-4")
Save Scan Settings
</button>
@@ -220,6 +219,7 @@ templ TunableSettingRow(e SettingEntry) {
</div>
<form
class="flex items-center gap-2 shrink-0"
data-settings-save="tunable"
hx-put="/admin/settings/tunable"
hx-target={ "#status-" + e.Key }
hx-swap="innerHTML"
@@ -252,7 +252,7 @@ templ TunableSettingRow(e SettingEntry) {
class="input py-1.5 text-sm w-40"
/>
}
<button type="submit" class="btn btn-secondary px-3 py-1.5 text-sm">
<button type="submit" class="btn btn-secondary px-3 py-1.5 text-sm" hx-disabled-elt="this">
@Icon("save", "h-3.5 w-3.5")
Save
</button>
@@ -275,3 +275,5 @@ templ AboutSection() {
</div>
</div>
}
+17 -17
View File
@@ -34,7 +34,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>System Settings - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>System Settings - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -92,7 +92,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 35, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 34, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -103,7 +103,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<form id=\"settings-form\" hx-put=\"/api/system/config\" hx-target=\"#settings-form\" hx-swap=\"outerHTML\"><div class=\"card p-6\"><div class=\"flex items-center gap-2 mb-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<form id=\"settings-form\" data-settings-save=\"settings\" hx-put=\"/api/system/config\" hx-target=\"#settings-form\" hx-swap=\"outerHTML\"><div class=\"card p-6\"><div class=\"flex items-center gap-2 mb-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -118,13 +118,13 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 49, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 48, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" placeholder=\"https://books.example.com\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p></div><div class=\"mt-6 flex justify-end\"><button type=\"submit\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" placeholder=\"https://books.example.com\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p></div><div class=\"mt-6 flex justify-end\"><button type=\"submit\" class=\"btn btn-primary\" hx-disabled-elt=\"this\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -395,7 +395,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 110, Col: 145}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 109, Col: 145}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -408,7 +408,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 111, Col: 144}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 110, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -421,7 +421,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 112, Col: 152}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 111, Col: 152}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -484,7 +484,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Scanning</h3></div><form hx-put=\"/admin/settings/scan\" hx-target=\"#scan-settings-section\" hx-swap=\"outerHTML\"><div class=\"space-y-4\"><div class=\"flex items-center justify-between gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\">Auto-Scan</label><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Watch libraries for file changes on startup</p></div><label class=\"relative inline-flex items-center cursor-pointer shrink-0\"><input type=\"checkbox\" name=\"auto_scan_enabled\" value=\"true\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Scanning</h3></div><form data-settings-save=\"scan\" hx-put=\"/admin/settings/scan\" hx-swap=\"none\"><div class=\"space-y-4\"><div class=\"flex items-center justify-between gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\">Auto-Scan</label><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Watch libraries for file changes on startup</p></div><label class=\"relative inline-flex items-center cursor-pointer shrink-0\"><input type=\"checkbox\" name=\"auto_scan_enabled\" value=\"true\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -501,13 +501,13 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 159, Col: 69}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 158, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" min=\"1\" max=\"3600\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">How often to poll libraries for changes (1–3600 seconds). Default: 60.</p></div><div class=\"flex justify-end\"><button type=\"submit\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" min=\"1\" max=\"3600\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">How often to poll libraries for changes (1–3600 seconds). Default: 60.</p></div><div class=\"flex justify-end\"><button type=\"submit\" class=\"btn btn-primary\" hx-disabled-elt=\"this\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -593,7 +593,7 @@ func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.C
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 199, Col: 112}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 198, Col: 112}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -651,7 +651,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 214, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 213, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -669,7 +669,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 216, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 215, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -687,7 +687,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 218, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 217, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -698,7 +698,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div><form class=\"flex items-center gap-2 shrink-0\" hx-put=\"/admin/settings/tunable\" hx-target=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div><form class=\"flex items-center gap-2 shrink-0\" data-settings-save=\"tunable\" hx-put=\"/admin/settings/tunable\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -832,7 +832,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<button type=\"submit\" class=\"btn btn-secondary px-3 py-1.5 text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<button type=\"submit\" class=\"btn btn-secondary px-3 py-1.5 text-sm\" hx-disabled-elt=\"this\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
-1
View File
@@ -67,7 +67,6 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Users - Bookhoard Admin</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
+11 -11
View File
@@ -70,7 +70,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Users - Bookhoard Admin</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Users - Bookhoard Admin</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -128,7 +128,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 102, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 101, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -149,7 +149,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 111, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 110, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -172,7 +172,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 116, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 115, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -185,7 +185,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 121, Col: 73}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 120, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -208,7 +208,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 138, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 137, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
@@ -221,7 +221,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 139, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 138, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -259,7 +259,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 150, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 149, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -272,7 +272,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 154, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 153, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
@@ -311,7 +311,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 173, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 172, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -324,7 +324,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 174, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 173, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
+1
View File
@@ -40,6 +40,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
data-title={ metadata.Title }
data-cover={ metadata.CoverImagePath }
data-file-url={ metadata.FileURL }
data-keep-awake={ metadata.KeepScreenAwake }
data-json={ fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
"/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress") }
+64 -51
View File
@@ -125,125 +125,138 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" data-json=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" data-keep-awake=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
"/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress"))
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.KeepScreenAwake)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 45, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 43, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts &amp; interactions\" title=\"Shortcuts &amp; interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semLine truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" data-json=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
"/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 46, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts &amp; interactions\" title=\"Shortcuts &amp; interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-seLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, rate := range SpeedOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 168, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" :selected=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("isDefaultSpeed(%g)", rate))
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" :selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("isDefaultSpeed(%g)", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</select></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<img src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 184, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 171, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" alt=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</select></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 185, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 185, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 186, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 194, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 195, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"onScrubInput($event)\" @change=\"commitScrub()\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"railTickStyle(tick)\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button type=\"button\" class=\"inline-block min-w-[4.75rem] text-right cursor-pointer\" x-text=\"endTimeText()\" :aria-label=\"endTimeAria()\" :title=\"endTimeAria()\" @click=\"cycleEndTLine truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"onScrubInput($event)\" @change=\"commitScrub()\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"railTickStyle(tick)\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button type=\"button\" class=\"inline-block min-w-[4.75rem] text-right cursor-pointer\" x-text=\"endTimeText()\" :aria-label=\"endTimeAria()\" :title=\"endTimeAria()\" @click=\"cycleEndTLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+1 -1
View File
@@ -38,7 +38,6 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ book.Title } - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
@@ -603,6 +602,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
@AnnotationsBanner(book)
if user.Role == "admin" {
@MetadataEditorModal(book)
@WriteToFileConfirmDialog(book)
}
<!-- Review editor: plain textarea over the RAW markdown (what
storage and sync carry); rendering happens only on display. -->
+62
View File
@@ -251,6 +251,7 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
id="metadata-editor-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
style="background-color: var(--surface-overlay);"
data-write-file-path={ book.FilePath }
>
<div
class="card w-full max-w-5xl my-8 flex flex-col max-h-[90vh]"
@@ -683,6 +684,18 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
<button
@click="writeMetadataToFile()"
:disabled="writingFile"
class="btn btn-secondary"
:title="metadataWriteHint()"
>
<span x-show="!writingFile" class="inline-flex items-center gap-2">@Icon("save", "h-4 w-4")<span>Write to file</span></span>
<svg x-show="writingFile" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
</div>
<div class="flex space-x-3">
<button
@@ -703,3 +716,52 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</div>
</div>
}
// WriteToFileConfirmDialog is the in-app replacement for the browser
// confirm() on the metadata editor's "Write to file" action (CSP and
// UX: no native dialogs). The target-specific copy comes from the
// bookDetail Alpine component (same scope as the editor modal).
templ WriteToFileConfirmDialog(book handlers.MediaDetail) {
<div
id="write-confirm-dialog"
x-show="writeConfirmOpen"
x-cloak
x-transition.opacity
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);"
>
<div class="card w-full max-w-md p-6" style="box-shadow: var(--shadow-pop);">
<div class="flex items-center gap-3 mb-4">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("save", "h-5 w-5")
</span>
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Write metadata to file?</h3>
</div>
<p class="text-sm mb-2" style="color: var(--text-secondary)" x-text="writeConfirmMessage()"></p>
<p class="text-xs mb-6" style="color: var(--text-secondary)">
Your pending edits are saved first, then the file is rewritten and re-scanned.
</p>
<div class="flex justify-end gap-3">
<button
type="button"
@click="writeConfirmOpen = false"
class="btn btn-secondary"
>
Cancel
</button>
<button
type="button"
@click="performWriteToFile()"
:disabled="writingFile"
class="btn btn-primary"
>
<span x-show="!writingFile" class="inline-flex items-center gap-2">@Icon("save", "h-4 w-4")<span>Save &amp; write</span></span>
<svg x-show="writingFile" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
</div>
</div>
</div>
}
File diff suppressed because it is too large. Load diff
+85 -77
View File
@@ -73,7 +73,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -102,7 +102,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FormatGroup)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 48, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 47, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -115,7 +115,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(book.LibraryID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 49, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 48, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -128,7 +128,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", getBookRating(book.Rating)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 50, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 49, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
@@ -141,7 +141,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflictID(book.ActiveConflict))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 51, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 50, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -154,7 +154,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflictWinnerSource(book.ActiveConflict))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 52, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 51, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
@@ -194,7 +194,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(templ.SafeCSS("background-image: url(" + cssURL(book.CoverImagePath.String) + ")"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 75, Col: 122}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 74, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -225,7 +225,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 87, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 86, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -238,7 +238,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 88, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 87, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
@@ -256,7 +256,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 96, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 95, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -274,7 +274,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 104, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 103, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -292,7 +292,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.Subtitle.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 106, Col: 91}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 105, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -322,7 +322,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs("/bookshelf?author_filter=" + url.QueryEscape(authorName))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 115, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 114, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -335,7 +335,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(authorName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 115, Col: 154}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 114, Col: 154}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
@@ -358,7 +358,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 120, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 119, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -402,7 +402,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var19 templ.SafeURL
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + uuidToString(book.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 137, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 136, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -423,7 +423,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.ActionLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 141, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 140, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -449,7 +449,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.MarkResetLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 145, Col: 137}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 144, Col: 137}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
@@ -475,7 +475,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.MarkCompleteLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 153, Col: 133}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 152, Col: 133}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -520,7 +520,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(len(book.DeletedAnnotations))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 170, Col: 134}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 169, Col: 134}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
@@ -551,7 +551,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(book.NotesCount + book.HighlightsCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 177, Col: 144}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 176, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -600,7 +600,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 240, Col: 84}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 239, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
@@ -629,7 +629,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var26 templ.SafeURL
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 250, Col: 125}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 249, Col: 125}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
@@ -642,7 +642,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(book.Series.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 254, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 253, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
@@ -660,7 +660,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(book.SeriesNumber.Float64))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 256, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 255, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
@@ -680,7 +680,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToUpper(book.ReadingDirection.String))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 261, Col: 142}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 260, Col: 142}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
@@ -726,7 +726,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var30 templ.SafeURL
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs("/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 277, Col: 109}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 276, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
@@ -739,7 +739,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(tag)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 281, Col: 16}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 280, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
@@ -767,7 +767,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Review.Review)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 294, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 293, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
@@ -818,7 +818,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(book.MetadataNotes.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 325, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 324, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
@@ -841,7 +841,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(book.ProgressHeader())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 331, Col: 95}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 330, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
@@ -872,7 +872,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 342, Col: 124}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 341, Col: 124}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
@@ -890,7 +890,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioCurrentLabel() + " / " + book.AudioDurationLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 350, Col: 144}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 349, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
@@ -908,7 +908,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 355, Col: 135}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 354, Col: 135}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
@@ -927,7 +927,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.CurrentPage.Int32)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 361, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 360, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
@@ -940,7 +940,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.TotalPages.Int32)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 361, Col: 131}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 360, Col: 131}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
@@ -959,7 +959,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastReadLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 366, Col: 111}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 365, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
@@ -972,7 +972,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 367, Col: 140}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 366, Col: 140}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
@@ -991,7 +991,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.LastSyncSource.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 373, Col: 110}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 372, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
@@ -1019,7 +1019,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(book.StatsHeader())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 382, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 381, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
@@ -1032,7 +1032,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(book.TimeStatLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 385, Col: 110}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 384, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
@@ -1050,7 +1050,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%dh %dmin", book.BookStats.TotalMinutes/60, book.BookStats.TotalMinutes%60))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 387, Col: 157}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 386, Col: 157}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
@@ -1068,7 +1068,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d min", book.BookStats.TotalMinutes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 389, Col: 119}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 388, Col: 119}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
@@ -1086,7 +1086,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.SessionCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 394, Col: 114}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 393, Col: 114}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
if templ_7745c5c3_Err != nil {
@@ -1104,7 +1104,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(book.TimeListenedLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 399, Col: 106}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 398, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
if templ_7745c5c3_Err != nil {
@@ -1122,7 +1122,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var49 string
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.TotalPages))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 404, Col: 113}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 403, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
if templ_7745c5c3_Err != nil {
@@ -1179,7 +1179,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var50 string
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(book.DatePublished.Time.Format("01-02-2006"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 431, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 430, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
if templ_7745c5c3_Err != nil {
@@ -1198,7 +1198,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var51 string
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(book.Isbn.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 437, Col: 64}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 436, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
if templ_7745c5c3_Err != nil {
@@ -1257,7 +1257,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var52 string
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioDurationLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 462, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 461, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
if templ_7745c5c3_Err != nil {
@@ -1296,7 +1296,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var53 string
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(book.PageCount.Int32)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 480, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 479, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
if templ_7745c5c3_Err != nil {
@@ -1329,7 +1329,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var54 string
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesCount.Int32)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 492, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 491, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
if templ_7745c5c3_Err != nil {
@@ -1348,7 +1348,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var55 string
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(book.Volume.Int32)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 498, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 497, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
if templ_7745c5c3_Err != nil {
@@ -1381,7 +1381,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var56 string
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(book.CopyrightYear.Int32)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 510, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 509, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
if templ_7745c5c3_Err != nil {
@@ -1400,7 +1400,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var57 string
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ReplaceAll(book.MangaType.String, "_", " "))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 516, Col: 118}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 515, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
if templ_7745c5c3_Err != nil {
@@ -1419,7 +1419,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var58 string
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(book.ScanInformation.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 522, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 521, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
if templ_7745c5c3_Err != nil {
@@ -1438,7 +1438,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var59 string
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(altSeries)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 528, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 527, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
if templ_7745c5c3_Err != nil {
@@ -1457,7 +1457,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var60 string
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(book.Contributors, ", "))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 534, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 533, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
if templ_7745c5c3_Err != nil {
@@ -1475,7 +1475,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var61 string
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(book.MimeType.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 539, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 538, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
if templ_7745c5c3_Err != nil {
@@ -1493,7 +1493,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var62 string
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(formatFileSize(book.FileSize.Int64))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 544, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 543, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
if templ_7745c5c3_Err != nil {
@@ -1512,7 +1512,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var63 string
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(book.FileLocation)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 550, Col: 113}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 549, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
if templ_7745c5c3_Err != nil {
@@ -1540,7 +1540,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var64 templ.SafeURL
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 557, Col: 106}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 556, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
if templ_7745c5c3_Err != nil {
@@ -1558,7 +1558,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var65 templ.SafeURL
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 559, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 558, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
if templ_7745c5c3_Err != nil {
@@ -1577,7 +1577,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var66 templ.SafeURL
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 562, Col: 110}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 561, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
if templ_7745c5c3_Err != nil {
@@ -1595,7 +1595,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var67 templ.SafeURL
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 564, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 563, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
if templ_7745c5c3_Err != nil {
@@ -1614,7 +1614,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var68 templ.SafeURL
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 567, Col: 110}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 566, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
if templ_7745c5c3_Err != nil {
@@ -1632,7 +1632,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var69 templ.SafeURL
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 569, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 568, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
if templ_7745c5c3_Err != nil {
@@ -1651,7 +1651,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var70 templ.SafeURL
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 572, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 571, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
if templ_7745c5c3_Err != nil {
@@ -1669,7 +1669,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var71 templ.SafeURL
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("amazon", "", book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 574, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 573, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
if templ_7745c5c3_Err != nil {
@@ -1688,7 +1688,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var72 templ.SafeURL
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinURLErrs(book.WebUrl.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 577, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 576, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
if templ_7745c5c3_Err != nil {
@@ -1701,7 +1701,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var73 string
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(getDomainName(book.WebUrl.String))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 577, Col: 238}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 576, Col: 238}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
if templ_7745c5c3_Err != nil {
@@ -1734,7 +1734,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var74 templ.SafeURL
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinURLErrs("/collections/" + uuidToString(col.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 589, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 588, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
if templ_7745c5c3_Err != nil {
@@ -1747,7 +1747,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var75 string
templ_7745c5c3_Var75, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("border-color: " + col.Color.String + "; background-color: var(--bg-primary); text-decoration: none;")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 591, Col: 118}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 590, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
if templ_7745c5c3_Err != nil {
@@ -1760,7 +1760,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var76 string
templ_7745c5c3_Var76, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("color: " + col.Color.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 593, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 592, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
if templ_7745c5c3_Err != nil {
@@ -1773,7 +1773,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var77 string
templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 593, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 592, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77))
if templ_7745c5c3_Err != nil {
@@ -1786,7 +1786,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var78 string
templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 594, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 593, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
if templ_7745c5c3_Err != nil {
@@ -1823,8 +1823,16 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = WriteToFileConfirmDialog(book).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "<!-- Review editor: plain textarea over the RAW markdown (what\n\t\t\t storage and sync carry); rendering happens only on display. --><div x-show=\"reviewOpen\" x-cloak class=\"fixed inset-0 flex items-center justify-center z-50 p-4\" style=\"background-color: var(--surface-overlay);\" @keydown.escape.window=\"reviewOpen = false\"><div class=\"absolute inset-0\" @click=\"reviewOpen = false\"></div><div class=\"relative card p-6 w-full max-w-2xl\"><h3 class=\"text-lg font-semibold mb-3\" style=\"color: var(--text-primary)\">Edit Review</h3><textarea x-model=\"reviewDraft\" rows=\"12\" class=\"w-full rounded-xl border p-3 text-sm font-mono resize-y\" style=\"background-color: var(--bg-primary); border-color: var(--border); color: var(--text-primary);\" placeholder=\"Markdown supported — **bold**, lists, footnotes…\"></textarea><p class=\"mt-1 text-xs\" style=\"color: var(--text-secondary)\">Markdown — renders like notes. Saving an empty review deletes it.</p><div class=\"mt-4 flex items-center gap-2\"><button type=\"button\" @click=\"saveReview()\" :disabled=\"reviewSaving\" class=\"btn btn-primary\"><span x-show=\"!reviewSaving\">Save review</span> <span x-show=\"reviewSaving\">Saving…</span></button> <button type=\"button\" @click=\"deleteReview()\" :disabled=\"reviewSaving || !hasReview\" class=\"btn btn-secondary\" style=\"color: var(--status-danger, #e5484d);\">Delete</button> <button type=\"button\" @click=\"reviewOpen = false\" class=\"btn btn-secondary ml-auto\">Cancel</button></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "<!-- Review editor: plain textarea over the RAW markdown (what\n\t\t\t storage and sync carry); rendering happens only on display. --><div x-show=\"reviewOpen\" x-cloak class=\"fixed inset-0 flex items-center justify-center z-50 p-4\" style=\"background-color: var(--surface-overlay);\" @keydown.escape.window=\"reviewOpen = false\"><div class=\"absolute inset-0\" @click=\"reviewOpen = false\"></div><div class=\"relative card p-6 w-full max-w-2xl\"><h3 class=\"text-lg font-semibold mb-3\" style=\"color: var(--text-primary)\">Edit Review</h3><textarea x-model=\"reviewDraft\" rows=\"12\" class=\"w-full rounded-xl border p-3 text-sm font-mono resize-y\" style=\"background-color: var(--bg-primary); border-color: var(--border); color: var(--text-primary);\" placeholder=\"Markdown supported — **bold**, lists, footnotes…\"></textarea><p class=\"mt-1 text-xs\" style=\"color: var(--text-secondary)\">Markdown — renders like notes. Saving an empty review deletes it.</p><div class=\"mt-4 flex items-center gap-2\"><button type=\"button\" @click=\"saveReview()\" :disabled=\"reviewSaving\" class=\"btn btn-primary\"><span x-show=\"!reviewSaving\">Save review</span> <span x-show=\"reviewSaving\">Saving…</span></button> <button type=\"button\" @click=\"deleteReview()\" :disabled=\"reviewSaving || !hasReview\" class=\"btn btn-secondary\" style=\"color: var(--status-danger, #e5484d);\">Delete</button> <button type=\"button\" @click=\"reviewOpen = false\" class=\"btn btn-secondary ml-auto\">Cancel</button></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1832,7 +1840,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
-1
View File
@@ -23,7 +23,6 @@ templ BookShelf(
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Library - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
+21 -21
View File
@@ -45,7 +45,7 @@ func BookShelf(
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Library - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Library - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -119,7 +119,7 @@ func BookShelf(
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 93, Col: 135}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 92, Col: 135}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -132,7 +132,7 @@ func BookShelf(
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 95, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 94, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -195,7 +195,7 @@ func BookShelf(
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 152, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -213,7 +213,7 @@ func BookShelf(
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -234,7 +234,7 @@ func BookShelf(
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -247,7 +247,7 @@ func BookShelf(
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -260,7 +260,7 @@ func BookShelf(
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -278,7 +278,7 @@ func BookShelf(
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -291,7 +291,7 @@ func BookShelf(
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -304,7 +304,7 @@ func BookShelf(
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -323,7 +323,7 @@ func BookShelf(
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 164, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -336,7 +336,7 @@ func BookShelf(
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 164, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -349,7 +349,7 @@ func BookShelf(
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 164, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -367,7 +367,7 @@ func BookShelf(
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 166, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -380,7 +380,7 @@ func BookShelf(
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 166, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -393,7 +393,7 @@ func BookShelf(
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 166, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -422,7 +422,7 @@ func BookShelf(
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 311, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 310, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -445,7 +445,7 @@ func BookShelf(
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 320, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 319, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -476,7 +476,7 @@ func BookShelf(
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 329, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 328, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -489,7 +489,7 @@ func BookShelf(
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 334, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 333, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
+46
View File
@@ -0,0 +1,46 @@
package templates
import "sync"
// Bundle versions: a short content hash per static JS bundle, set once
// at server startup. Script tags render /static/<bundle>.js?v=<hash>,
// so any rebuild that changes a bundle's bytes automatically changes
// its cache key — browsers fetch the new bundle with no manual stamp
// bumps (the manual ?v=N stamp is exactly how a fixed search dropdown
// stayed broken for one deploy: the HTML kept pointing at the old,
// cached URL).
//
// An unset bundle renders ?v=0; the /static route also sends
// Cache-Control: no-cache, so worst case browsers revalidate — the
// hash is the fast path, not the correctness mechanism.
var bundleVersionsMu sync.RWMutex
var bundleVersions = map[string]string{}
// SetBundleVersion records the content hash for a bundle ("main.js").
// Called once at startup for each built bundle.
func SetBundleVersion(name, hash string) {
bundleVersionsMu.Lock()
defer bundleVersionsMu.Unlock()
bundleVersions[name] = hash
}
// BundleVersion returns the recorded version for a bundle, or "0".
func BundleVersion(name string) string {
bundleVersionsMu.RLock()
defer bundleVersionsMu.RUnlock()
if v, ok := bundleVersions[name]; ok && v != "" {
return v
}
return "0"
}
// MainJSVersion is the templ-side accessor for the main bundle stamp.
func MainJSVersion() string {
return BundleVersion("main.js")
}
// ReaderJSVersion is the templ-side accessor for the reader bundle stamp.
func ReaderJSVersion() string {
return BundleVersion("reader.js")
}
-1
View File
@@ -7,7 +7,6 @@ templ CollectionRules(user User, collection CollectionData) {
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Collection Rules - { collection.Name } - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
+6 -6
View File
@@ -42,7 +42,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -71,7 +71,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 14, Col: 120}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 13, Col: 120}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -100,7 +100,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: var(--accent-muted); color: " + collection.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 159}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 22, Col: 159}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -113,7 +113,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 179}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 22, Col: 179}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -126,7 +126,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 25, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 24, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -147,7 +147,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 49, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 48, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
+1 -3
View File
@@ -97,7 +97,6 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Collections - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
@@ -212,13 +211,12 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ collection.Name } - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="collections" x-init="initCollectionsPage()" class={ "theme-" + user.Theme }>
@Header(user, "/collections")
@LibrarySwitcher(libData, libraryID, "")
@LibrarySwitcher(libData, libraryID, "", nil)
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<a href="/collections" class="btn btn-secondary mb-4">
+32 -32
View File
@@ -307,7 +307,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
templ_7745c5c3_Var15 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Collections - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Collections - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -395,7 +395,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 159, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 158, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
@@ -408,7 +408,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 162, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 161, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
@@ -421,7 +421,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 165, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 164, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -444,7 +444,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 171, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 170, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -465,7 +465,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 180, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
@@ -486,7 +486,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 190, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 189, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
@@ -499,7 +499,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 191, Col: 81}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 190, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -512,7 +512,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 193, Col: 103}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 192, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
@@ -577,13 +577,13 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 214, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 213, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -613,7 +613,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = LibrarySwitcher(libData, libraryID, "").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = LibrarySwitcher(libData, libraryID, "", nil).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -632,7 +632,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: var(--accent-muted); color: " + collection.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 229, Col: 149}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 227, Col: 149}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
@@ -645,7 +645,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 229, Col: 169}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 227, Col: 169}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
@@ -658,7 +658,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 232, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 230, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
@@ -681,7 +681,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 237, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 235, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
@@ -709,7 +709,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var34 templ.SafeURL
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs("/collections/" + collection.ID + "/rules")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 261, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 259, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
@@ -778,7 +778,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 339, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 337, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil {
@@ -791,7 +791,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 351, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 349, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
if templ_7745c5c3_Err != nil {
@@ -804,7 +804,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 363, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 361, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
if templ_7745c5c3_Err != nil {
@@ -825,7 +825,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 383, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 381, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
if templ_7745c5c3_Err != nil {
@@ -851,7 +851,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 438, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 436, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
if templ_7745c5c3_Err != nil {
@@ -864,7 +864,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 439, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 437, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
if templ_7745c5c3_Err != nil {
@@ -877,7 +877,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 440, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 438, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
if templ_7745c5c3_Err != nil {
@@ -890,7 +890,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSmart)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 441, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 439, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
if templ_7745c5c3_Err != nil {
@@ -933,7 +933,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 451, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 449, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
if templ_7745c5c3_Err != nil {
@@ -951,7 +951,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 455, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 453, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
if templ_7745c5c3_Err != nil {
@@ -964,7 +964,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 455, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 453, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
if templ_7745c5c3_Err != nil {
@@ -982,7 +982,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 457, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 455, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
if templ_7745c5c3_Err != nil {
@@ -1000,7 +1000,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 460, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 458, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
if templ_7745c5c3_Err != nil {
@@ -1013,7 +1013,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var49 string
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 465, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 463, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
if templ_7745c5c3_Err != nil {
@@ -1026,7 +1026,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var50 string
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 472, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 470, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
if templ_7745c5c3_Err != nil {
-1
View File
@@ -92,7 +92,6 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Sync Conflicts - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
+1 -1
View File
@@ -323,7 +323,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
templ_7745c5c3_Var15 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Sync Conflicts - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Sync Conflicts - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
-1
View File
@@ -7,7 +7,6 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Create Custom Section - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
+3 -3
View File
@@ -29,7 +29,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Create Custom Section - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Create Custom Section - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -87,7 +87,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 31}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 73, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -100,7 +100,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 73, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
+73 -40
View File
@@ -12,13 +12,12 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Dashboard - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="dashboard" x-init="initDashboard()" class={ "theme-" + user.Theme }>
@Header(user, "/dashboard")
@LibrarySwitcher(libData, currentLibraryID, "Your Library", DashboardActions())
@LibrarySwitcher(libData, currentLibraryID, "Your Library", weekWidgetComponent(week, activeLibType), DashboardActions())
<!-- OUTSIDE #collections-container on purpose: the switcher's
re-render wipes that container's innerHTML, and anything
server-rendered inside it dies on the first library
@@ -41,11 +40,6 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
</a>
</div>
}
if week != nil {
<div class="w-full px-4 sm:px-6 lg:px-8 pt-6">
@DashboardWeekWidget(*week, activeLibType)
</div>
}
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 pt-5 pb-8">
for _, section := range sections {
@CollectionCarousel(section)
@@ -149,7 +143,7 @@ templ BookCard(item handlers.BookInfo) {
/>
}
<!-- Media-type identifier (A2c): books stay unbadged — the
shelf default already reads as a book -->
shelf default already reads as a book -->
if MediaBadgeIcon(item.Badge) != "" {
<span
class="absolute top-2 right-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm"
@@ -159,6 +153,25 @@ templ BookCard(item handlers.BookInfo) {
@Icon(MediaBadgeIcon(item.Badge), "h-4 w-4")
</span>
}
<!-- Viewer progress (2026-10-27 search-progress batch):
completed check top-left, partial bar along the cover
bottom. Renders only when the feeding adapter loaded
the requester's progress (search today). -->
if item.ViewerPercentage != nil {
if *item.ViewerPercentage >= CompletedProgressThreshold {
<span
class="absolute top-2 left-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm"
style="background-color: color-mix(in srgb, var(--bg-primary) 78%, transparent); color: var(--accent); backdrop-filter: blur(3px);"
title="Completed"
>
@Icon("check", "h-4 w-4")
</span>
} else if *item.ViewerPercentage > 0 {
<div class="absolute bottom-0 left-0 right-0 z-10 h-1" style="background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);">
<div class="h-full" style={ progressFillCSS(*item.ViewerPercentage) }></div>
</div>
}
}
</div>
<div class="book-card-meta">
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
@@ -317,41 +330,56 @@ type BookStatsData struct {
FirstSession string
}
// DashboardWeekWidget renders the collapsible "This Week" strip. The
// collapsed summary row reads at a glance on every screen size; the
// expanded body holds the full grid. The details element re-fetches
// itself through htmx when the body sees a `week-refresh` event
// (dispatched by the library switcher), carrying the CURRENT
// #library-select value, so the widget tracks the switched library.
// weekWidgetComponent adapts the optional week stats into the
// LibrarySwitcher's center slot. nil (stats unavailable or the stats
// service is off) = the slot stays empty, exactly as when the widget
// used to be suppressed entirely.
func weekWidgetComponent(week *WeekStatsData, activeLibType string) templ.Component {
if week == nil {
return nil
}
return DashboardWeekWidget(*week, activeLibType)
}
// DashboardWeekWidget renders the "This Week" stats that live in the
// library bar's center slot (LibrarySwitcher's `center` component).
// The summary is a compact inline row whose numbers reveal themselves
// as width allows; the expanded body is an absolutely positioned
// popover that FLOATS over the page content — opening it never shifts
// the collections below. The details element re-fetches itself through
// htmx when the body sees a `week-refresh` event (dispatched by the
// library switcher), carrying the CURRENT #library-select value, so
// the widget tracks the switched library. Visible at every width — on
// narrow screens the summary collapses to the chart icon + chevron
// (tap target, aria-labeled; "This Week" + minutes reveal at `sm`,
// pages/sessions/books at md/lg/xl). The panel anchors to the
// BAR (full-width under it) below `sm` — the widget is too far left on
// phones for widget-centered anchoring — and centers on the widget at
// `sm` and up.
templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
<details
id="week-widget"
class="card group"
class="group sm:relative"
hx-get="/dashboard/week-widget"
hx-trigger="week-refresh from:body"
hx-include="#library-select"
hx-swap="outerHTML"
>
<summary class="px-4 md:px-6 py-3 flex flex-wrap items-center gap-x-5 gap-y-1 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
<span class="flex items-center gap-2 font-bold tracking-tight" style="color: var(--text-primary)">
<span class="grid place-items-center h-6 w-6 rounded-md" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("chart", "h-4 w-4")
</span>
This Week
<summary class="flex items-center gap-2 cursor-pointer select-none list-none rounded-lg px-2 py-1 hover:bg-surface-hover [&::-webkit-details-marker]:hidden" aria-label="Reading stats for this week">
<span class="grid place-items-center h-6 w-6 rounded-md shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("chart", "h-4 w-4")
</span>
<span class="text-sm font-semibold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d pages", week.WeekPages) }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d sessions", week.WeekSessions) }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d books", week.WeekBooks) }</span>
<span class="text-sm font-bold tracking-tight whitespace-nowrap hidden sm:inline" style="color: var(--text-primary)">This Week</span>
<span class="text-sm font-semibold whitespace-nowrap hidden sm:inline" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</span>
<span class="text-xs whitespace-nowrap hidden md:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d pages", week.WeekPages) }</span>
<span class="text-xs whitespace-nowrap hidden lg:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d sessions", week.WeekSessions) }</span>
<span class="text-xs whitespace-nowrap hidden xl:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d books", week.WeekBooks) }</span>
if week.CurrentStreak > 0 {
<span class="text-sm font-medium" style="color: var(--accent)">🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak</span>
<span class="text-xs font-medium whitespace-nowrap hidden lg:inline" style="color: var(--accent)">🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak</span>
}
<span class="ml-auto flex items-center gap-3">
<a href="/stats" class="text-xs font-medium hover:underline" style="color: var(--accent);" @click.stop>Full stats</a>
@Icon("chevron-down", "h-4 w-4 transition-transform group-open:rotate-180")
</span>
@Icon("chevron-down", "h-4 w-4 shrink-0 transition-transform group-open:rotate-180")
</summary>
<div class="px-4 md:px-6 pb-4 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
<div class="card absolute top-full left-4 right-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 mt-2 sm:w-[30rem] max-w-[calc(100vw-2rem)] z-40 p-4 md:px-6 md:py-5" style="box-shadow: var(--shadow-pop);">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
if activeLibType == "audiobooks" {
<div>
@@ -387,16 +415,21 @@ templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
</div>
}
</div>
<p class="text-xs mt-3" style="color: var(--text-secondary)">
Synced from your reading devices
if week.CurrentStreak > 0 {
if week.ActiveToday {
{ fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak) }
} else {
{ fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak) }
<div class="flex items-end justify-between gap-4 mt-3 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
<p class="text-xs" style="color: var(--text-secondary)">
Synced from your reading devices
if week.CurrentStreak > 0 {
if week.ActiveToday {
{ fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak) }
} else {
{ fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak) }
}
}
}
</p>
</p>
<a href="/stats" class="inline-flex items-center gap-1 text-xs font-medium hover:underline shrink-0 whitespace-nowrap" style="color: var(--accent);">Full stats
@Icon("arrow-right", "h-4 w-4")
</a>
</div>
</div>
</details>
}
File diff suppressed because it is too large. Load diff
-1
View File
@@ -9,7 +9,6 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Device Management - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
+21 -21
View File
@@ -31,7 +31,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Device Management - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Device Management - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -133,7 +133,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue("showShelfMappings('" + device.ID.String() + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 64, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 63, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -154,7 +154,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 67, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 66, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -175,7 +175,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 102}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 71, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -188,7 +188,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 73, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -221,7 +221,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 86, Col: 104}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 85, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -249,7 +249,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 94, Col: 104}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 93, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -277,7 +277,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("sync-url-" + device.ID.String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 108, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 107, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -290,7 +290,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 110, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 109, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -303,7 +303,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyFromId('sync-url-" + device.ID.String() + "', 'Kobo sync URL')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 114, Col: 91}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 113, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
@@ -330,7 +330,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("auth-token-" + device.ID.String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 130, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 129, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -343,7 +343,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 132, Col: 38}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 131, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -356,7 +356,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyFromId('auth-token-" + device.ID.String() + "', 'Auth token')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 136, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 135, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -382,7 +382,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 147, Col: 78}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 146, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
@@ -423,7 +423,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 168, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 167, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
@@ -436,7 +436,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 169, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -449,7 +449,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 169, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -462,7 +462,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 174, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 173, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
@@ -475,7 +475,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 177, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 176, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -514,7 +514,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 235, Col: 148}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 234, Col: 148}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -527,7 +527,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 237, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 236, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
+1 -1
View File
@@ -72,7 +72,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
</main>
</div>
<!-- module bundle; ?v=<stamp> busts stale intermediate builds -->
<script type="module" src="/static/main.js?v=7"></script>
<script type="module" src={ "/static/main.js?v=" + MainJSVersion() }></script>
</body>
</html>
}
+235 -222
View File
@@ -264,7 +264,20 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></main></div><!-- module bundle; ?v=<stamp> busts stale intermediate builds --><script type=\"module\" src=\"/static/main.js?v=7\"></script></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></main></div><!-- module bundle; ?v=<stamp> busts stale intermediate builds --><script type=\"module\" src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 75, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\"></script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -291,12 +304,12 @@ func DocsTopbar(user User) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
if templ_7745c5c3_Var12 == nil {
templ_7745c5c3_Var12 = templ.NopComponent
templ_7745c5c3_Var13 := templ.GetChildren(ctx)
if templ_7745c5c3_Var13 == nil {
templ_7745c5c3_Var13 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<header class=\"sticky top-0 z-30 h-14 shrink-0 border-b flex items-center gap-2 sm:gap-3 px-4 sm:px-6\" style=\"background: var(--bg-primary); border-color: var(--border);\"><button type=\"button\" class=\"lg:hidden icon-btn shrink-0\" @click=\"toggleSidebar()\" aria-label=\"Toggle documentation menu\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<header class=\"sticky top-0 z-30 h-14 shrink-0 border-b flex items-center gap-2 sm:gap-3 px-4 sm:px-6\" style=\"background: var(--bg-primary); border-color: var(--border);\"><button type=\"button\" class=\"lg:hidden icon-btn shrink-0\" @click=\"toggleSidebar()\" aria-label=\"Toggle documentation menu\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -304,7 +317,7 @@ func DocsTopbar(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</button> <a href=\"/docs\" class=\"flex items-center gap-2 shrink-0 no-underline\"><span class=\"grid place-items-center\" style=\"color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</button> <a href=\"/docs\" class=\"flex items-center gap-2 shrink-0 no-underline\"><span class=\"grid place-items-center\" style=\"color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -312,7 +325,7 @@ func DocsTopbar(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span> <span class=\"hidden sm:inline font-bold text-lg tracking-tight\" style=\"color: var(--text-primary);\">Bookhoard</span> <span class=\"text-xs font-semibold px-2 py-0.5 rounded-full\" style=\"background: var(--accent-muted); color: var(--accent);\">Docs</span></a><div class=\"flex-1\"></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span> <span class=\"hidden sm:inline font-bold text-lg tracking-tight\" style=\"color: var(--text-primary);\">Bookhoard</span> <span class=\"text-xs font-semibold px-2 py-0.5 rounded-full\" style=\"background: var(--accent-muted); color: var(--accent);\">Docs</span></a><div class=\"flex-1\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -320,7 +333,7 @@ func DocsTopbar(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"hidden md:block h-6 w-px shrink-0\" style=\"background: var(--border);\"></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"hidden md:block h-6 w-px shrink-0\" style=\"background: var(--border);\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -328,25 +341,25 @@ func DocsTopbar(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"hidden md:flex items-center gap-2 shrink-0\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"hidden md:flex items-center gap-2 shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.ID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a href=\"/profile\" class=\"icon-btn\" title=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<a href=\"/profile\" class=\"icon-btn\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("Signed in as " + user.Username)
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("Signed in as " + user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 109, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" aria-label=\"Your profile\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" aria-label=\"Your profile\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -354,17 +367,17 @@ func DocsTopbar(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<a href=\"/login\" class=\"text-sm font-medium hover:underline\" style=\"color: var(--text-secondary);\">Sign in</a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<a href=\"/login\" class=\"text-sm font-medium hover:underline\" style=\"color: var(--text-secondary);\">Sign in</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<a href=\"/\" class=\"btn btn-primary py-1.5 px-3 text-sm no-underline\">Open App</a></div></header>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a href=\"/\" class=\"btn btn-primary py-1.5 px-3 text-sm no-underline\">Open App</a></div></header>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -391,12 +404,12 @@ func DocsSearchBox() templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var14 := templ.GetChildren(ctx)
if templ_7745c5c3_Var14 == nil {
templ_7745c5c3_Var14 = templ.NopComponent
templ_7745c5c3_Var15 := templ.GetChildren(ctx)
if templ_7745c5c3_Var15 == nil {
templ_7745c5c3_Var15 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"relative flex-1 max-w-72\" x-data=\"{ open: false }\" @click.outside=\"open = false\" @keydown.escape=\"open = false\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"relative flex-1 max-w-72\" x-data=\"{ open: false }\" @click.outside=\"open = false\" @keydown.escape=\"open = false\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -404,7 +417,7 @@ func DocsSearchBox() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span> <input type=\"text\" class=\"search-input input pl-9 py-1.5 text-sm\" placeholder=\"Search docs…\" id=\"docs-search\" autocomplete=\"off\"><div id=\"docs-search-results\" class=\"hidden absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] max-h-96 overflow-y-auto rounded-2xl border shadow-pop z-40\" style=\"background: var(--bg-secondary); border-color: var(--border);\"></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span> <input type=\"text\" class=\"search-input input pl-9 py-1.5 text-sm\" placeholder=\"Search docs…\" id=\"docs-search\" autocomplete=\"off\"><div id=\"docs-search-results\" class=\"hidden absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] max-h-96 overflow-y-auto rounded-2xl border shadow-pop z-40\" style=\"background: var(--bg-secondary); border-color: var(--border);\"></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -432,84 +445,84 @@ func DocsThemePicker() templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var15 := templ.GetChildren(ctx)
if templ_7745c5c3_Var15 == nil {
templ_7745c5c3_Var15 = templ.NopComponent
templ_7745c5c3_Var16 := templ.GetChildren(ctx)
if templ_7745c5c3_Var16 == nil {
templ_7745c5c3_Var16 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div x-data=\"theme\" x-init=\"initializeTheme()\" class=\"shrink-0\"><select id=\"theme-select\" @change=\"changeTheme($event.target.value)\" class=\"input py-1.5 pr-8 text-sm cursor-pointer w-auto\" aria-label=\"Theme\"><optgroup label=\"Dark\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<div x-data=\"theme\" x-init=\"initializeTheme()\" class=\"shrink-0\"><select id=\"theme-select\" @change=\"changeTheme($event.target.value)\" class=\"input py-1.5 pr-8 text-sm cursor-pointer w-auto\" aria-label=\"Theme\"><optgroup label=\"Dark\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, opt := range ThemeOptionsFor(ModeDark) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 164, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 164, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 164, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</optgroup> <optgroup label=\"Light\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, opt := range ThemeOptionsFor(ModeLight) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<option value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 169, Col: 29}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 164, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 169, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</option>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</optgroup></select></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</optgroup> <optgroup label=\"Light\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, opt := range ThemeOptionsFor(ModeLight) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 169, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 169, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</optgroup></select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -536,17 +549,17 @@ func DocsSidebar(nav Navigation, currentPath string) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var20 := templ.GetChildren(ctx)
if templ_7745c5c3_Var20 == nil {
templ_7745c5c3_Var20 = templ.NopComponent
templ_7745c5c3_Var21 := templ.GetChildren(ctx)
if templ_7745c5c3_Var21 == nil {
templ_7745c5c3_Var21 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<aside id=\"docs-sidebar\" class=\"sidebar fixed lg:sticky top-14 bottom-0 lg:bottom-auto left-0 z-40 lg:z-10 w-72 shrink-0 lg:h-[calc(100vh-3.5rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line\"><nav class=\"py-2\" aria-label=\"Documentation\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<aside id=\"docs-sidebar\" class=\"sidebar fixed lg:sticky top-14 bottom-0 lg:bottom-auto left-0 z-40 lg:z-10 w-72 shrink-0 lg:h-[calc(100vh-3.5rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line\"><nav class=\"py-2\" aria-label=\"Documentation\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, section := range nav.Sections {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"nav-section border-b border-line last:border-b-0 pb-2\"><button type=\"button\" class=\"nav-section-title w-full flex items-center gap-2.5 px-4 py-2.5 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" @click=\"toggleCollapsible($el)\"><span class=\"shrink-0\" style=\"color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<div class=\"nav-section border-b border-line last:border-b-0 pb-2\"><button type=\"button\" class=\"nav-section-title w-full flex items-center gap-2.5 px-4 py-2.5 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" @click=\"toggleCollapsible($el)\"><span class=\"shrink-0\" style=\"color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -554,33 +567,33 @@ func DocsSidebar(nav Navigation, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span> <span class=\"flex-1 text-left\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 196, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span> <span class=\"section-arrow shrink-0 transition-transform\" style=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span> <span class=\"flex-1 text-left\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(chevronStyle(section.Collapsed))
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 197, Col: 103}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 196, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span> <span class=\"section-arrow shrink-0 transition-transform\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(chevronStyle(section.Collapsed))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 197, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -588,101 +601,101 @@ func DocsSidebar(nav Navigation, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span></button><div class=\"nav-items pb-1\" data-collapsed=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</span></button><div class=\"nav-items pb-1\" data-collapsed=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(collapsibleState(section.Collapsed))
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collapsibleState(section.Collapsed))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 201, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range section.Items {
var templ_7745c5c3_Var24 = []any{docsNavClass(currentPath, item.URL, "pl-7")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...)
var templ_7745c5c3_Var25 = []any{docsNavClass(currentPath, item.URL, "pl-7")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var25...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 templ.SafeURL
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
var templ_7745c5c3_Var26 templ.SafeURL
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 203, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var24).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\"><span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var25).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 204, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, group := range section.Groups {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"mt-0.5\"><button type=\"button\" class=\"nav-group-title w-full flex items-center gap-1.5 pl-7 pr-4 py-1.5 cursor-pointer select-none text-xs font-semibold uppercase tracking-wide transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" @click=\"toggleCollapsible($el)\"><span class=\"flex-1 text-left truncate\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"><span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(group.Title)
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 215, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 204, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</span> <span class=\"section-arrow shrink-0 transition-transform\" style=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, group := range section.Groups {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div class=\"mt-0.5\"><button type=\"button\" class=\"nav-group-title w-full flex items-center gap-1.5 pl-7 pr-4 py-1.5 cursor-pointer select-none text-xs font-semibold uppercase tracking-wide transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" @click=\"toggleCollapsible($el)\"><span class=\"flex-1 text-left truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(chevronStyle(group.Collapsed))
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(group.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 216, Col: 104}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 215, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span> <span class=\"section-arrow shrink-0 transition-transform\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(chevronStyle(group.Collapsed))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 216, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -690,84 +703,84 @@ func DocsSidebar(nav Navigation, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></button><div class=\"nav-group-items\" data-collapsed=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</span></button><div class=\"nav-group-items\" data-collapsed=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(collapsibleState(group.Collapsed))
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(collapsibleState(group.Collapsed))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 220, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range group.Items {
var templ_7745c5c3_Var31 = []any{docsNavClass(currentPath, item.URL, "pl-9")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var31...)
var templ_7745c5c3_Var32 = []any{docsNavClass(currentPath, item.URL, "pl-9")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 templ.SafeURL
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
var templ_7745c5c3_Var33 templ.SafeURL
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 222, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var31).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\"><span class=\"truncate\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var32).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 223, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span></a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"><span class=\"truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 223, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</span></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</nav></aside>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</nav></aside>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -794,77 +807,77 @@ func DocsSearchResults(results []SearchResultItem) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var35 := templ.GetChildren(ctx)
if templ_7745c5c3_Var35 == nil {
templ_7745c5c3_Var35 = templ.NopComponent
templ_7745c5c3_Var36 := templ.GetChildren(ctx)
if templ_7745c5c3_Var36 == nil {
templ_7745c5c3_Var36 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if len(results) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div class=\"px-4 py-3 text-sm\" style=\"color: var(--text-secondary);\">No matching pages.</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<div class=\"px-4 py-3 text-sm\" style=\"color: var(--text-secondary);\">No matching pages.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<ul class=\"py-1 list-none m-0 p-0\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<ul class=\"py-1 list-none m-0 p-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, r := range results {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<li><a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<li><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 templ.SafeURL
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(r.URL)
var templ_7745c5c3_Var37 templ.SafeURL
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(r.URL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 246, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" class=\"block px-4 py-2 no-underline transition-colors hover:bg-surface-hover\" @click=\"closeSearchResults($event)\"><span class=\"block text-sm font-medium truncate\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 247, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" class=\"block px-4 py-2 no-underline transition-colors hover:bg-surface-hover\" @click=\"closeSearchResults($event)\"><span class=\"block text-sm font-medium truncate\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 247, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if r.Category != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"block text-xs\" style=\"color: var(--text-secondary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"block text-xs\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Category)
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(r.Category)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 249, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</a></li>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</ul>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -891,12 +904,12 @@ func DocsNotFound(title string, message string) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var39 := templ.GetChildren(ctx)
if templ_7745c5c3_Var39 == nil {
templ_7745c5c3_Var39 = templ.NopComponent
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
if templ_7745c5c3_Var40 == nil {
templ_7745c5c3_Var40 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<!doctype html><html lang=\"en\"><head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<!doctype html><html lang=\"en\"><head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -904,20 +917,20 @@ func DocsNotFound(title string, message string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(title)
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 267, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, " - Bookhoard Documentation</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"min-h-screen font-sans antialiased\"><div class=\"min-h-screen flex items-center justify-center p-4\"><div class=\"card p-8 max-w-md w-full text-center\"><span class=\"inline-grid place-items-center mb-4\" style=\"color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " - Bookhoard Documentation</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"min-h-screen font-sans antialiased\"><div class=\"min-h-screen flex items-center justify-center p-4\"><div class=\"card p-8 max-w-md w-full text-center\"><span class=\"inline-grid place-items-center mb-4\" style=\"color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -925,33 +938,33 @@ func DocsNotFound(title string, message string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span><h1 class=\"text-2xl font-bold mb-3\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 277, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</h1><p class=\"text-sm mb-6\" style=\"color: var(--text-secondary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</span><h1 class=\"text-2xl font-bold mb-3\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(message)
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 278, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 277, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</p><div class=\"flex items-center justify-center gap-3\"><a href=\"/docs\" class=\"btn btn-primary py-2 px-4 text-sm no-underline\">Documentation</a> <a href=\"/\" class=\"btn py-2 px-4 text-sm no-underline\">Bookhoard</a></div></div></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</h1><p class=\"text-sm mb-6\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 278, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</p><div class=\"flex items-center justify-center gap-3\"><a href=\"/docs\" class=\"btn btn-primary py-2 px-4 text-sm no-underline\">Documentation</a> <a href=\"/\" class=\"btn py-2 px-4 text-sm no-underline\">Bookhoard</a></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+33 -4
View File
@@ -4,6 +4,12 @@ templ Header(user User, currentPath string) {
<div
x-data="header"
>
<!-- htmx loads with the header (classic script, before the module
bundle): the header's live search is htmx-driven on EVERY
page, so the include lives HERE — not per-page. Pages that
render @Header must not include htmx.min.js themselves
(double processing). -->
<script src="/static/htmx.min.js"></script>
<!-- Mobile backdrop -->
<div
class="app-sidebar-backdrop lg:hidden"
@@ -224,23 +230,46 @@ templ Header(user User, currentPath string) {
>
@Icon("menu", "h-5 w-5")
</button>
<div class="relative flex-1 max-w-xl">
<div class="relative flex-1 max-w-xl" x-data="headerSearch" @click.outside="close()">
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
@Icon("search", "h-4 w-4")
</span>
<input
type="text"
id="header-search"
name="q"
placeholder="Search your library…"
class="input pl-10"
autocomplete="off"
x-ref="input"
role="combobox"
:aria-expanded="open ? 'true' : 'false'"
aria-label="Search your library"
@input="onInput()"
@focus="onFocus()"
@keydown.arrow-down.prevent="moveSelection(1)"
@keydown.arrow-up.prevent="moveSelection(-1)"
@keydown.enter.prevent="openSelected()"
@keydown.escape="close()"
hx-get="/api/media-items/search?layout=dropdown"
hx-trigger="search"
hx-target="#search-results"
hx-swap="innerHTML"
/>
<div
id="search-results"
x-show="open"
x-cloak
class="absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden"
style="background-color: var(--bg-secondary); border-color: var(--border)"
></div>
</div>
</div>
</header>
<!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate
builds across app restarts (browser caches are keyed by URL) -->
<script type="module" src="/static/main.js?v=7"></script>
<!-- module bundle; ?v=<content hash> busts stale intermediate
builds across app restarts (browser caches are keyed by URL);
the hash is computed at startup from the served file -->
<script type="module" src={ "/static/main.js?v=" + MainJSVersion() }></script>
</div>
}
+56 -43
View File
@@ -29,7 +29,7 @@ func Header(user User, currentPath string) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"header\"><!-- Mobile backdrop --><div class=\"app-sidebar-backdrop lg:hidden\" :class=\"{ 'is-open': mobileMenuOpen }\" @click=\"mobileMenuOpen = false\" x-cloak></div><!-- Sidebar --><aside class=\"app-sidebar\" :class=\"{ 'is-open': mobileMenuOpen }\"><!-- Logo --><a href=\"/dashboard\" class=\"flex items-center gap-2.5 px-5 h-16 shrink-0 border-b\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><span class=\"grid place-items-center h-6 w-6 shrink-0\" style=\"color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"header\"><!-- htmx loads with the header (classic script, before the module\n\t\t bundle): the header's live search is htmx-driven on EVERY\n\t\t page, so the include lives HERE — not per-page. Pages that\n\t\t render @Header must not include htmx.min.js themselves\n\t\t (double processing). --><script src=\"/static/htmx.min.js\"></script><!-- Mobile backdrop --><div class=\"app-sidebar-backdrop lg:hidden\" :class=\"{ 'is-open': mobileMenuOpen }\" @click=\"mobileMenuOpen = false\" x-cloak></div><!-- Sidebar --><aside class=\"app-sidebar\" :class=\"{ 'is-open': mobileMenuOpen }\"><!-- Logo --><a href=\"/dashboard\" class=\"flex items-center gap-2.5 px-5 h-16 shrink-0 border-b\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><span class=\"grid place-items-center h-6 w-6 shrink-0\" style=\"color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -254,7 +254,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("sidebarShell('" + string(ThemeModeOf(user.Theme)) + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 86, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 92, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
@@ -298,7 +298,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 115, Col: 104}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 121, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -319,7 +319,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeDark)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 119, Col: 78}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 125, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -332,7 +332,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 125, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 131, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
@@ -353,7 +353,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeLight)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 129, Col: 79}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 135, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -371,7 +371,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === '" + string(opt.Mode) + "'")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 139, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 145, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -384,7 +384,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 140, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 146, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
@@ -397,7 +397,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(opt.Mode))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 141, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 147, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
@@ -410,7 +410,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 144, Col: 131}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 150, Col: 131}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -423,7 +423,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 145, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 151, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
@@ -476,7 +476,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 157, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 163, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
@@ -489,7 +489,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 159, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 165, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil {
@@ -512,7 +512,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 165, Col: 191}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 171, Col: 191}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
@@ -530,7 +530,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 167, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 173, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
@@ -717,7 +717,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</button><div class=\"relative flex-1 max-w-xl\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</button><div class=\"relative flex-1 max-w-xl\" x-data=\"headerSearch\" @click.outside=\"close()\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -725,7 +725,20 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=7\"></script></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <input type=\"text\" id=\"header-search\" name=\"q\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\" x-ref=\"input\" role=\"combobox\" :aria-expanded=\"open ? 'true' : 'false'\" aria-label=\"Search your library\" @input=\"onInput()\" @focus=\"onFocus()\" @keydown.arrow-down.prevent=\"moveSelection(1)\" @keydown.arrow-up.prevent=\"moveSelection(-1)\" @keydown.enter.prevent=\"openSelected()\" @keydown.escape=\"close()\" hx-get=\"/api/media-items/search?layout=dropdown\" hx-trigger=\"search\" hx-target=\"#search-results\" hx-swap=\"innerHTML\"><div id=\"search-results\" x-show=\"open\" x-cloak class=\"absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"></div></div></div></header><!-- module bundle; ?v=<content hash> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL);\n\t\t the hash is computed at startup from the served file --><script type=\"module\" src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 272, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\"></script></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -750,12 +763,12 @@ func SidebarUserMenu(user User) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var42 := templ.GetChildren(ctx)
if templ_7745c5c3_Var42 == nil {
templ_7745c5c3_Var42 = templ.NopComponent
templ_7745c5c3_Var43 := templ.GetChildren(ctx)
if templ_7745c5c3_Var43 == nil {
templ_7745c5c3_Var43 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -763,20 +776,20 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span> <span class=\"flex-1 text-left truncate\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span> <span class=\"flex-1 text-left truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 259, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 288, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -784,7 +797,7 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -792,7 +805,7 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -800,7 +813,7 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span>Logout</span></button></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span>Logout</span></button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -826,12 +839,12 @@ func SidebarSignIn(currentPath string) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var44 := templ.GetChildren(ctx)
if templ_7745c5c3_Var44 == nil {
templ_7745c5c3_Var44 = templ.NopComponent
templ_7745c5c3_Var45 := templ.GetChildren(ctx)
if templ_7745c5c3_Var45 == nil {
templ_7745c5c3_Var45 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -839,7 +852,7 @@ func SidebarSignIn(currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -847,30 +860,30 @@ func SidebarSignIn(currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 307, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 336, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if SignupVisible() {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+12 -2
View File
@@ -1,6 +1,13 @@
package templates
templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, actions ...templ.Component) {
// LibrarySwitcher renders the sticky subbar: title + library select on
// the left (title hidden below `sm` — phones need the row width for the
// select + actions + the dashboard's stats widget), optional action
// buttons on the right. `center` is an
// optional slot between them (nil = unused) — the dashboard passes its
// "This Week" stats widget here so it participates in the bar's
// justify-between layout as the middle group.
templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, center templ.Component, actions ...templ.Component) {
<div
class="app-subbar backdrop-blur border-b"
style="background-color: color-mix(in srgb, var(--bg-primary) 88%, transparent); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);"
@@ -8,7 +15,7 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
<div class="w-full px-4 sm:px-6 lg:px-8 py-3 flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
if title != "" {
<span class="text-base font-bold tracking-tight shrink-0" style="color: var(--text-primary)">{ title }</span>
<span class="text-base font-bold tracking-tight shrink-0 hidden sm:inline" style="color: var(--text-primary)">{ title }</span>
} else {
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library</label>
}
@@ -33,6 +40,9 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
}
</select>
</div>
if center != nil {
@center
}
if len(actions) > 0 {
<div class="flex items-center gap-2">
for _, action := range actions {
+29 -16
View File
@@ -8,7 +8,14 @@ package templates
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, actions ...templ.Component) templ.Component {
// LibrarySwitcher renders the sticky subbar: title + library select on
// the left (title hidden below `sm` — phones need the row width for the
// select + actions + the dashboard's stats widget), optional action
// buttons on the right. `center` is an
// optional slot between them (nil = unused) — the dashboard passes its
// "This Week" stats widget here so it participates in the bar's
// justify-between layout as the middle group.
func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, center templ.Component, actions ...templ.Component) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -34,14 +41,14 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
return templ_7745c5c3_Err
}
if title != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"text-base font-bold tracking-tight shrink-0\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"text-base font-bold tracking-tight shrink-0 hidden sm:inline\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 11, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 18, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -64,7 +71,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 20, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 27, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -84,7 +91,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 24, Col: 29}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -97,7 +104,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 24, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -110,7 +117,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 24, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -128,7 +135,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 26, Col: 29}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
@@ -141,7 +148,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 26, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -154,7 +161,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 26, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -173,7 +180,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 29, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -186,7 +193,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 29, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -199,7 +206,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 29, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -217,7 +224,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -230,7 +237,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -243,7 +250,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -259,6 +266,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if center != nil {
templ_7745c5c3_Err = center.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(actions) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
+18 -3
View File
@@ -68,7 +68,7 @@ func TestDashboardUnlinkedBanner(t *testing.T) {
}
}
// The unlinked-books page renders queue rows standalone.
// The unlinked-books page renders queue rows standalone, both buckets.
func TestUnlinkedBooksPageRender(t *testing.T) {
var b strings.Builder
user := User{ID: "u1", Timezone: "UTC"}
@@ -81,11 +81,26 @@ func TestUnlinkedBooksPageRender(t *testing.T) {
TitleFromDevice: "Some Sideloaded Book",
LastSyncTime: "2026-10-18 12:00",
}}
if err := UnlinkedBooks(user, books).Render(context.Background(), &b); err != nil {
ignored := []UnlinkedBookData{{
ProgressID: "ub2",
DeviceName: "KOReader Phone",
DeviceType: "koreader",
FilePath: "/sdcard/ignored.epub",
SHA256: "def456",
TitleFromDevice: "Some Dismissed Book",
LastSyncTime: "2026-10-18 12:00",
}}
if err := UnlinkedBooks(user, books, ignored).Render(context.Background(), &b); err != nil {
t.Fatalf("render: %v", err)
}
out := b.String()
for _, want := range []string{"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches"} {
for _, want := range []string{
"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches",
// Dismiss/delete actions and the ignored recovery bucket.
"Some Dismissed Book", `hx-post="/unlinked-books/ub1/ignore"`,
`hx-delete="/unlinked-books/ub1"`, `hx-post="/unlinked-books/ub2/unignore"`,
"Ignored (1)",
} {
if !strings.Contains(out, want) {
t.Errorf("unlinked page missing %q", want)
}
-1
View File
@@ -7,7 +7,6 @@ templ Profile(user User) {
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Profile Settings - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
+10
View File
@@ -87,6 +87,16 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
}
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Keep Screen Awake</label>
<select
name="keep_screen_awake"
class="input"
>
<option value="true" selected?={ user.KeepScreenAwake }>On</option>
<option value="false" selected?={ !user.KeepScreenAwake }>Off</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label>
<select
+86 -66
View File
@@ -228,277 +228,297 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Keep Screen Awake</label> <select name=\"keep_screen_awake\" class=\"input\"><option value=\"true\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "UTC" {
if user.KeepScreenAwake {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">On</option> <option value=\"false\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Pacific/Honolulu" {
if !user.KeepScreenAwake {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Off</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Anchorage" {
if user.Timezone == "UTC" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Los_Angeles" {
if user.Timezone == "Pacific/Honolulu" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Denver" {
if user.Timezone == "America/Anchorage" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Phoenix" {
if user.Timezone == "America/Los_Angeles" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Chicago" {
if user.Timezone == "America/Denver" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/New_York" {
if user.Timezone == "America/Phoenix" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Sao_Paulo" {
if user.Timezone == "America/Chicago" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/London" {
if user.Timezone == "America/New_York" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/Paris" {
if user.Timezone == "America/Sao_Paulo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/Helsinki" {
if user.Timezone == "Europe/London" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/Moscow" {
if user.Timezone == "Europe/Paris" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Tehran" {
if user.Timezone == "Europe/Helsinki" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Dubai" {
if user.Timezone == "Europe/Moscow" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Karachi" {
if user.Timezone == "Asia/Tehran" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Kolkata" {
if user.Timezone == "Asia/Dubai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Dhaka" {
if user.Timezone == "Asia/Karachi" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Bangkok" {
if user.Timezone == "Asia/Kolkata" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Shanghai" {
if user.Timezone == "Asia/Dhaka" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Tokyo" {
if user.Timezone == "Asia/Bangkok" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Australia/Darwin" {
if user.Timezone == "Asia/Shanghai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Australia/Sydney" {
if user.Timezone == "Asia/Tokyo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Pacific/Auckland" {
if user.Timezone == "Australia/Darwin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, ">New Zealand (UTC+12/+13)</option></select></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Australia/Sydney" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Pacific/Auckland" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, ">New Zealand (UTC+12/+13)</option></select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if showRoleField {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Role == "user" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, ">User</option> <option value=\"admin\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, ">User</option> <option value=\"admin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, ">Admin</option></select></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, ">Admin</option></select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -506,12 +526,12 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if requireCurrentPassword {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"/api/auth/password\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"/api/auth/password\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -519,25 +539,25 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 211, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 221, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -545,22 +565,22 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if showCancelButton {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<button type=\"submit\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -568,7 +588,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more