Compare commits

...
53 Commits
Author SHA1 Message Date
John O'Keefe 1c1f5cf1bb feat(version): bake release tag into binary; About card and /health field
Release / build-and-push (push) Successful in 3m1s
The version tag previously existed only in git and the Docker image tag —
the running app had no way to report what build it was. Version is now
injected at build time via -ldflags into internal/version.Version (defaults
to "dev" for local builds), passed by the release workflow as the
APP_VERSION Docker build arg from the pushed tag.

Surfaced in Admin → Settings → About (extensible card for future rows like
disk space) and in the /health JSON response, so deployments can be
verified with curl alone.
2026-09-21 20:21:43 -04:00
John O'Keefe 9e4cc4efd0 chore(templates): regenerate templ outputs and rebuilt static assets
Release / build-and-push (push) Successful in 2m42s
Regenerates all *_templ.go files after the templ generate pass (version
stamp updates across the board; functional changes only in
admin_archived_templ.go and admin_library_templ.go, which were committed
with their features). web/static/style.css and web/static/htmx.min.js are
refreshed outputs of npm run build (tailwind pass over the current
templates; htmx copied from the current node_modules version).
2026-09-21 18:10:47 -04:00
John O'Keefe 7b0b2793a6 test(infra): self-healing admin password; cross-library move/merge suite
setupTestServer reuses the shared dev admin (testuser@tests.bookhoard.internal)
instead of re-inserting it, but never reset its password — once any test
mutated the admin's password, every later test in the run failed to log in
with 401s until the database was manually wiped. The seeding step now
resets the password hash to the known test constant on reuse, so full
integration runs are repeatable against an existing database.

Adds cross_library_move_test.go, seven integration tests covering the
duplicate-content features end to end:

- TestCrossLibraryMovePreservesHistory: a book moved between two same-type
  libraries keeps its row ID — progress and annotations survive, no
  duplicate, no archived ghost, library_type_name stays truthful.
- TestCrossLibraryCopyStaysIndependent: deliberate copies in two libraries
  stay independent rows with isolated progress.
- TestCrossLibraryMoveRejectedForTypeMismatch: a reflowable EPUB is not
  repointed between manga libraries; a library_type_mismatch processing
  issue is recorded instead.
- TestListHiddenMediaItemsMatchesActiveTwin: archived rows expose their
  active same-SHA twin (and rows without one report no match).
- TestMergeArchivedItemIntoActiveTwin: merging moves progress and
  annotations onto the active copy and removes the archived row.
- TestMergeArchivedItemRejections: non-admin 403, active source, hash
  mismatch, missing target_id, self-merge, and archived target all fail
  closed.
- TestLibraryTypeNameTriggerOnLibraryChange: the UPDATE trigger refreshes
  library_type_name when a row changes libraries.

Tests create their own temp-dir library folders (host runs don't have
/app/uploads), use unique device identifiers (device_identifier is
UNIQUE and leftover rows broke reruns), and clean up via an explicit
defer that runs while the pool is still open — a t.Cleanup registered
for the same purpose silently no-ops because it executes after
setup.Close has closed the pool.
2026-09-21 18:10:37 -04:00
John O'Keefe 077afaca56 chore(docker): bake Go module cache into the test-runner image
The test-runner stage copied only /app from the builder, but the Go module
cache lives in /go/pkg/mod (GOMODCACHE) — the cache mounts used by the
builder target /root/go/pkg/mod, which the go tool ignores, so those mounts
never held anything. Every `compose run tests` / make test-integration
invocation therefore re-downloaded all dependencies from the network,
making integration runs slow and timeout-prone.

The builder now materializes /go/pkg/mod into an image layer
(go-module-cache) after the binary build, and the test-runner stage
restores it to /go/pkg/mod before running tests. Test-only stage; the
production final stage is unaffected.
2026-09-21 18:10:17 -04:00
John O'Keefe 388b62b277 fix(admin): delete-library warning names what is lost; modal now closes
Three fixes to the Delete Library flow on /admin/library:

1. Honest warning copy. The old text ("Media files will not be deleted")
   read as reassurance while deleting a library actually cascade-destroys
   every book record with it: reading progress, highlights, notes,
   bookmarks, and ratings are gone with no archive window and no undo.
   The modal now states this as a scannable list:
   - Permanently removed, no archive or undo: the library, its folder
     mappings, and all book records — with their reading progress,
     highlights, notes, bookmarks, and ratings.
   - Not touched: media files on disk.
   Admins skim danger dialogs; the irreversible part now leads.

2. The dialog stayed open after confirming. The confirm button swaps
   #libraries-container via htmx (so the deleted row vanished) but the
   modal lives outside the swapped container and nothing closed it. An
   htmx:afterRequest listener now hides the modal on successful deletes
   and leaves it open on errors.

3. Latent ReferenceError in openFolderBrowser: the function parameter is
   targetInputId but the body referenced an undefined targetInput, so
   opening the folder browser threw and the picker never loaded.
2026-09-21 18:09:52 -04:00
John O'Keefe 5da97b2e5d feat(opds): library folder navigation and library-scoped search
The device catalog flattened every visible library into one list, so
duplicate copies of the same book each got an entry and search had no
library context. The root feed is now a standard OPDS 1.1 navigation feed
that mirrors the web UI's library model:

- Root /catalog: an "All Books" entry first (the previous flat cross-
  library behavior, also still served at ?all=1 for clients that want
  the single flat list), followed by one folder entry per visible
  library with live book counts from GetVisibleLibraryMediaCounts.
- New GET /library/:libraryId/catalog: acquisition feed scoped to one
  visible library (403 when the device owner cannot see it), paginated,
  with an up-link back to the root.
- Scoped search: each library feed's rel="search" OpenSearch template
  pins &library_id=<id>, so opening search from inside a library folder
  searches only that library — clients substitute only {searchTerms},
  so no client-side changes are required. Root search stays global.
- Global search entries now carry the owning library as a category (and
  as a fallback summary when the book has no description), so duplicate
  copies are distinguishable in unscoped result lists.

The acquisition entry builder is extracted into addAcquisitionEntries and
shared by the all-books and per-library feeds. bookhoard.koplugin needs
no changes: it only registers the root URL, and KOReader's stock OPDS
client renders navigation feeds natively.

Tests: TestOPDSLibraryFolders covers the nav-feed shape, flat ?all=1
mode, scoped catalog isolation, scoped/global search behavior, and 403s
for libraries outside the device's visibility. Library names avoid the
word "test" on purpose — setupDeviceTest re-runs setupTestServer's
setup-time cleanup, which deletes every library whose name contains it.
2026-09-21 18:09:30 -04:00
John O'Keefe 45e3a256e2 feat(collections): merge archived duplicates into their active copy
Covers the copy-then-delete-later workflow: a user copies books to a new
library, deletes the originals, and ends up with an active copy in the new
library plus an archived twin holding the real reading history. Until now
those twins could only be purged (destroying the history) or restored
(showing a permanently broken entry).

- POST /api/media-items/:id/merge (admin only, body {target_id}):
  validates the source is archived/missing, the target is active, and
  both share the same file_sha256; then re-parents every child row onto
  the target via the existing reparent_media_item_children function (the
  same machinery as hash-conflict resolution) and deletes the source row.
  Per-user collisions keep the active copy's data, mirroring that flow.
  Affected data: reading progress, speed, ratings, highlights, notes,
  bookmarks (including tombstoned deleted-annotation history), formats,
  collections, kobo shelves/entitlements, sync rows, panel data,
  processing issues, and device aliases.
- GET /admin/archived: ListHiddenMediaItems' match_* columns surface each
  row's best active twin; rows with a twin get a confirm-guarded "Merge"
  button (data-merge-source/-target) next to Restore/Delete, wired in
  web/src/admin.ts like the existing unarchive/delete handlers.
- templates.ArchivedItem gains MatchID/MatchTitle/MatchLibraryName;
  frontend.go populates them from the listing row.

After a merge the archived row is gone, so the retention purge can never
destroy the merged data. Deleted-annotation tombstones carry over and
remain restorable from the target book's "recently deleted" history.
2026-09-21 18:09:05 -04:00
John O'Keefe 64ef691d64 feat(scanner): repoint rows on cross-library moves of identical content
Previously the scanner's SHA-256 dedup was library-scoped: moving a book
between libraries created a duplicate row (new ID) while the old row went
missing and archived after two scans, orphaning reading progress and
annotations from the file that users still see.

processMediaFile now falls through to cross-library detection when the
same-library hash lookup misses:

- GetMediaItemsBySHA256AndSameLibraryType returns candidates in other
  same-type libraries; each candidate's file is stat'd through ITS OWN
  library's folders (the old existence check stat'd against the current
  scan's folder tree, which is meaningless across libraries).
- File gone -> it is a move: the candidate must pass the target library's
  type rules (ValidateMediaItemForLibrary), then the row is repointed via
  MoveMediaItemToLibrary with the recomputed relative path. Reading
  history, annotations, and collections follow automatically because the
  row keeps its ID. Incompatible formats (e.g. a reflowable EPUB into a
  manga library) are rejected with a processing issue instead of being
  force-imported; the upsert on (media_item_id, issue_type) keeps
  repeated scans from spamming duplicates.
- File still present -> deliberate multi-library copy: fall through to
  normal import so both libraries keep independent rows.

Candidate selection is factored into selectMoveCandidate (pure function,
unit-tested in media_scanner_move_test.go): input arrives pre-ordered
(archived first, then most missing scans, then oldest) and only
candidates whose file is verifiably gone qualify, so deliberate copies
are never repointed.
2026-09-21 18:08:43 -04:00
John O'Keefe a4dbe79d0b feat(data): duplicate-content queries — archive filters, cross-library moves, archived-twin matching
Three related query groups in queries.sql (sqlc regenerated; no generated
signatures changed, so no caller edits were needed):

1. Dashboard/collection archive filters. The smart-section and collection
   queries returned archived and missing items (visible only as broken
   covers once their files vanished). Added the codebase-standard
   "AND archived_at IS NULL AND missing_scan_count = 0" predicate to:
   GetContinueReadingItems, GetRecentlyAddedItems, GetRecentlyReadItems,
   GetNotStartedItems, GetCollectionItemsForDashboard, GetLibraryItems.
   Matches the existing convention in ListMediaItems, SearchMediaItems,
   the library counts, and GetContinueSeriesItems.

2. Cross-library SHA move detection support.
   - GetMediaItemsBySHA256AndSameLibraryType: finds identical content
     (same file_sha256) registered in a DIFFERENT library of the SAME
     library type, ordered archived-first, most-missing, oldest. Type
     scoping keeps ebooks from merging into manga/comics libraries.
   - MoveMediaItemToLibrary: repoints an existing row to the new library
     (library_id + recomputed file_path + file_size) and clears
     archive/missing state. The row keeps its ID, so reading progress,
     annotations, collections, and kobo shelves follow the book
     automatically; the new set_library_type_name_on_library_change
     trigger refreshes the denormalized type column.

3. Archived-twin matching. ListHiddenMediaItems (admin archived-items
   page) now LEFT JOIN LATERALs the best active same-SHA twin per hidden
   row — preferring same library type, then same library, then most
   reading progress, then oldest — exposing match_id / match_title /
   match_library_name so the UI can offer merging the archived row's
   reading data into its active duplicate. COALESCE on match_title keeps
   the no-match case NULL-safe.
2026-09-21 18:08:18 -04:00
John O'Keefe 1002cbca93 fix(schema): refresh library_type_name on library change; make trigger block re-runnable
schema.sql is re-executed in full on every application startup, so every
statement in it must be idempotent. Two related problems introduced with the
cross-library move feature, plus their fix:

Problem 1: media_items.library_type_name was only populated by a BEFORE
INSERT trigger. When the scanner repoints an existing row to a different
library (cross-library move detection), the denormalized library_type_name
went stale, mislabeling the item's type for validation and display.

Fix: add a second trigger, set_library_type_name_on_library_change, that
fires BEFORE UPDATE OF library_id and re-runs the same population function.

Problem 2 (outage): the new trigger EXECUTE FUNCTIONs set_library_type_name(),
but the pre-existing idempotency dance drops that function on every startup
before recreating it. On the second and later boots, DROP FUNCTION failed
with SQLSTATE 2BP01 (function still depended on by the trigger created by
the previous boot), aborting the whole schema transaction and crash-looping
the container.

Fix: drop BOTH triggers before dropping the function, and create both after
it. The ordering now survives any number of restarts on any database state.

Adds TestSchemaInitializationIsIdempotent, which replays the production
database.Initialize twice against the same database so this class of
"second startup" regression fails in CI instead of in production. Note the
test uses a dedicated pgxpool: Initialize holds the advisory lock on one
connection while executing the schema on another, which deadlocks against
the shared test pool's MaxConns=1.
2026-09-21 18:07:51 -04:00
John O'Keefe 65f6bdf075 chore: complete go.sum with test-graph dependency sums
go mod download all added sums for modules only reachable from test
builds (chroma, bluemonday, compress families); without them the
integration-test binary build fetches missing sums at compile time.
2026-09-18 18:09:20 -04:00
John O'Keefe 4faa217822 test: repair integration harness — wire SettingsRegistry, reuse preserved dev admin
Two pre-existing harness breaks found while running the device tests:

- RegisterRoutes dereferences cfg.Settings (auth rate limit) but
  setupTestServer never set it — every integration test nil-panicked at
  route registration. Wire database.NewSettingsRegistry(queries) the
  same way main.go does.
- The cleanup deliberately PRESERVES testuser@tests.bookhoard.internal
  (shared dev admin), but setup then blindly re-INSERTed that user, so
  every run after the first failed on users_email_key. Reuse the user
  when it exists; default collections are created only for a NEW user
  (the preserved admin already has its set).

Also documented the offline runner used on slow links: build the test
binary on the host (CGO_ENABLED=0 go test -c) and execute it inside the
bookhoard-tests image against the compose network — no in-container
module downloads, no image rebuild.
2026-09-18 16:06:14 -04:00
John O'Keefe 5cdfe5a8dd fix(devices): release pendingMu on the pending-status path of CheckRegistrationStatus
The mutex hardening added a fall-through 'pending' response path that
returned while still holding pendingMu — one status poll before approval
leaked the lock and every later register/approve/status request hung
forever (caught by TestDeviceRegistrationFlow's approve step hanging).
2026-09-18 16:06:03 -04:00
John O'Keefe 1cd8557b58 fix(devices): re-approving a known device rotates its token instead of 500
App reinstalls that preserve data (Android Studio installDebug over an
existing install) re-register with the same device_identifier, but the
devices row from the previous install still exists — device_identifier
is UNIQUE, so ApproveDevice's blind INSERT failed with a unique
violation and returned 500 'failed to create device' (reproduced via
curl: second approve with the same identifier = instant 500; the ~98s
in the original report was app-side retry/polling, not server wait).

ApproveDevice is now idempotent: look the device up by identifier
first; a row owned by the approving user gets its auth token rotated
via UpdateDeviceAuthToken (row id unchanged, so synced highlights/
bookmarks/progress anchored to it stay valid; fresh install = fresh
credentials, old token invalidated); a row owned by another user gets
409; unknown identifiers INSERT as before, with the 23505 race falling
through to the rotate path. DB failures are logged (they were silent).

Also guard the in-memory pendingRegistrations map with a mutex —
register/approve/reject/status/list all touch it from HTTP goroutines,
and a racing write is a Go runtime fatal, not an error. The approver's
credential publication and the status poller's approved-branch snapshot
now run under the lock so the token can never be read half-written.

Regression test: TestApproveDeviceReapprovalRotatesToken — register →
approve → re-register same identifier → approve (must be 200) → token
rotated, exactly one devices row, row carries the new token.
2026-09-17 23:09:02 -04:00
John O'Keefe b4c956aed4 docs(user): web reader guide — navigation, touch selection, highlights
User-facing documentation for the rebuilt web reader (§6.5 of the app
handoff): swipe-to-page / tap-for-menu touch navigation, long-press word
selection with drag extension and handles, the selection popover (color
dots, notes with save-together-on-create, copy incl. plain-HTTP LAN,
edit/delete by tapping a painted highlight), below-the-selection popover
placement rationale, PDF/comic behavior, and annotations drawer.
Linked from the user documentation portal and the docs index quick
links / quick-find tables.
2026-09-17 20:13:45 -04:00
John O'Keefe 7caa46c2da fix(build): exclude local package-lock.json from Docker build context
The lockfile is gitignored (each machine keeps its own), but
.dockerignore did not exclude it, so any stale local lock rode into
every docker build via `COPY package*.json`. Because the forked
foliate-js declares "version": "0.0.0" on every commit, npm treats
the git pin as already satisfied by name@version and never
re-resolves the new commit hash — silently installing and bundling
the old code. This bit both the host npm cache mount (documented at
Dockerfile:18-20) and, today, `make rebuild-app-force`: a fresh
no-cache image was built with the pre-feature 1305a52 foliate-js
(chunk fixed-layout-B8-qRQLl.js) despite package.json pinning
e16530a, while the Gitea runner (fresh checkout, no lockfile, cold
cache) built correctly.

With no lockfile in the context, npm install resolves git pins
fresh from package.json each build (tarballs are cached by
commit-specific URLs), so the persistent npm cache mount cannot
serve old commits across pin bumps. Local lockfiles can no longer
poison builds even if regenerated on the host.

Verified: after evicting the poisoned cache mounts
(docker builder prune --filter type=exec.cachemount) and rebuilding,
the container serves fixed-layout-BE0KdOql.js with both dblclick
handlers present, matching the reference build.
2026-09-17 15:37:58 -04:00
john-okeefe 5329bf35a9 fix: pin foliate-js e16530a — desktop double-click zoom toggle
Release / build-and-push (push) Successful in 3m20s
Bump the linuxhg mirror pin from 1305a52 to e16530a (mirror main is
already synced). The fork adds a desktop double-click zoom to the
fixed-layout renderer for all fixed-layout content (comics, manga,
PDFs, fixed EPUBs):

- at fit scale, double-click zooms to 2.5x at the clicked location
- zoomed or panned, double-click resets to 100% centered
- PDF text-layer spans and annotation links keep native double-click
  (word selection) in Smart/Text modes; Pan mode zooms anywhere
- mirrors the existing touch double-tap; disabled in panel-zoom mode
  like the other gestures

Also closes the gap where node_modules was serving a stale tarball
older than the previous 1305a52 pin (missing the toolbar-resize
debounce and selection-drag fixes); a fresh install now resolves the
full set.
2026-09-17 14:37:05 -04:00
john-okeefe 80e5d5b0d1 feat(reader): comic treatment for fixed-layout items in comics/manga libraries
PDFs shelved in comics or manga libraries (scanned manga, official manga
PDFs with text layers) now get comic reader treatment: bookmarks only —
no text-selection highlights, no annotations, no in-book search. Items
in ebooks libraries are completely unaffected, and EPUBs keep their
existing behavior everywhere.

- handlers: new exported ShouldTreatAsComic(libraryType, formatGroup) —
  true for fixed_layout/comic_archive in manga/comics libraries; the
  reader JSON API also exposes it as treat_as_comic
- router: the SSR reader page fetches the library type and passes
  TreatAsComic through ReaderMetadata into the init config
- reader: when treatAsComic is set, the PDF textLayer selection listener
  (mouse and touch paths) never attaches so no highlight popover can
  open; pointer mode is forced to pan (Smart/Text segment hidden);
  search button and toggleSearch are disabled; previously-created PDF
  highlights stop rendering
- bookmarks are unchanged (already page-index based for fixed layout),
  and device sync / OPDS / format classification are untouched since
  format_group stays fixed_layout
2026-09-17 14:36:58 -04:00
John O'Keefe b3a429d7e1 fix(reader): Save/Cancel in the note editor closes the whole popover
Release / build-and-push (push) Successful in 2m46s
Cancel only collapsed the editor section, and edit-mode saves only set
noteOpen=false — the minimized popover (colors/pencil/copy/delete) stayed
up. Explicit Save and Cancel now call hideSelectionPopover; color tweaks
with the editor open still keep it open.
2026-09-17 11:47:25 -04:00
John O'Keefe d8179bb066 fix(reader): popover dismiss on PDF taps; notes save on create; colors don't close editor
- The in-book pointerdown dismiss listener lived in the reflowable-only
  block, so PDF taps never closed the selection popover — hoist it so
  every content doc (EPUB, PDF, comics) dismisses on tap.
- createHighlight hardcoded note_text: '' — 'Highlight with note' saved
  the highlight but silently dropped the note. Send p.note (and pass it
  to the EPUB overlayer).
- saveHighlightChanges collapsed the note editor on every save; color
  dots in create mode created a highlight outright. With the editor
  open, color dots now just recolor (create) or recolor-and-save with
  the editor kept open (edit).
2026-09-17 10:55:21 -04:00
John O'Keefe 960305dfa9 fix(reader): PDF selection popover shows reliably on touch, below selection
Chromium's touch selection takeover swallows pointerup in the fx iframe,
so the PDF popover's only trigger never fired on real phones — it only
appeared when timing happened to deliver the event. Mirror the EPUB
path: a selectionchange debounce (400ms settle) opens the popover, with
the pointerup path kept for desktop plus the quick-tap word-select guard.

Also place the popover BELOW the selection on coarse pointers (+90px
clearing the native selection menu and drag handles), matching the
reflowable path; desktop keeps above-placement.
2026-09-17 10:40:12 -04:00
John O'Keefe 16bbeec5a3 fix: fresh npm cache mount id to evict stale foliate-js tarball 2026-09-17 10:17:34 -04:00
John O'Keefe 39f9c39360 fix: pin foliate-js 1305a52 via linuxhg mirror
GitHub-pinned builds silently reused a stale foliate-js tarball from
Docker's npm cache mount (name@version never changed from 0.0.0);
every build since the GitHub pin shipped pre-94bb384 code. Repin to
the mirror and bust the cache mount.
2026-09-17 10:13:33 -04:00
John O'Keefe 011b6d14e4 fix: pin foliate-js 1305a52 — fx renderer ignores toolbar resizes during selection
The fx renderer re-rendered the PDF on every viewport resize;
mobile browser toolbar transitions (7-17% height change) during a
text selection drag caused the old canvas to be cleared for
re-rendering while the async pdf.js render raced with the next
resize, blanking the page. Now gated by the same 25% threshold
as the paginator.
2026-09-16 17:30:00 -04:00
John O'Keefe d4661e7f04 fix: pin foliate-js 94bb384 — PDF selection drags no longer blank the page
The fx renderer's gesture classifier only checked whether the touch
started on a .textLayer span; Chromium's long-press selects the
nearest word even when the finger landed between spans, so the
classifier saw a non-selectable target and classified the drag as
swipe/pan — preventDefault on the iframe's touchmove then cancelled
the native selection extension mid-drag and could blank the PDF
canvas. If any frame already has a non-collapsed selection, the
drag is now classified as native.
2026-09-16 17:13:24 -04:00
John O'Keefe 27a93ba2ea fix(reader): hide the chrome when a touch selection starts
Release / build-and-push (push) Successful in 2m28s
pokeChrome's anySelection gate only prevents the chrome from being
raised — if it was already visible when the selection began, it
stayed up. noteSelectionActivity now actively hides it the moment
a selection appears.
2026-09-15 20:20:34 -04:00
John O'Keefe 7afad5dcde chore: point foliate-js at linuxhg mirror main (8a20399) 2026-09-15 20:13:07 -04:00
John O'Keefe 929a862e74 feat(reader): touch selection handles, copy fallback, highlight tap editing
- Custom drag handles (start/end) for post-lift selection adjustment:
  the native handles are disabled with the rest of the native touch
  selection controller; these are positioned at the selection's
  boundary carets and driven through the same clamped caret mapping
- Copy button falls back to execCommand via a transient textarea on
  plain HTTP (navigator.clipboard is unavailable on LAN addresses);
  both paths clear the selection and dismiss the popover
- Only dismiss the selection popover on actual position changes in
  relocate (detail.section is a fresh object on every relocate, so
  reference comparison always saw a move); a tap's no-op relocate
  was closing the just-opened highlight edit popover
- Pin foliate-js f872a01 (synthetic click dispatch on quick taps)
  and 422e8e0 (don't snap taps that never panned)
2026-09-15 19:33:45 -04:00
John O'Keefe 0e55dafb1e fix(reader): mobile touch reading overhaul
- tap zones: map in-iframe taps into the visible page slice (the
  iframe is laid out at full section width; every tap previously
  computed as the left margin) and shrink the default zone size to 12%
- hyphens: manual on coarse pointers: Chrome's touch word-selection
  walks hyphen fragments, re-anchoring line-start drags and
  overshooting line ends into the next column
- selection popover: opens for settled touch selections (the gesture
  takeover swallows pointerup), positioned below the selection with
  on-screen clamping; creating a highlight clears the selection so
  the browser's own menu follows
- settle-time recovery: return the view to the selection's anchor
  page and clamp the selection to the visible page after the
  browser's selection auto-scroll wanders off the page grid
- overscroll-behavior: none on the reader page (pull-to-refresh
  during downward drags)
2026-09-15 08:50:17 -04:00
John O'Keefe cc8084a713 fix(reader): pin foliate-js 9cfe266
Non-touch gate on selection auto-paging (drag-selecting no longer
pages away mid-gesture) and small height-only viewport resizes no
longer re-wrap the book (mobile browser toolbar transitions).
2026-09-15 08:50:06 -04:00
John O'Keefe 3ab294bf81 feat(scanner): store EPUB reading direction at scan time
Release / build-and-push (push) Successful in 2m33s
Fixed-layout EPUBs lean on the reading_direction column (the web reader
forces book.dir = rtl from it when the file didn't set direction
itself), but the scanner never populated it for EPUBs - only ComicInfo
fed it. Meanwhile real Japanese EPUBs declare page-progression-direction
on the OPF spine, which foliate reads client-side but nothing stored.

Read the spine attribute in the structured OPF parser and map it into
ReadingDirection in parseOPFContent (EPUB2/3, case-insensitive, plus
'right-to-left'/'left-to-right' spellings); undeclared stays empty
rather than forcing ltr, preserving the editor's Auto default. Sidecar
OPFs are metadata-only documents without spines, so the Calibre path is
a no-op. The merge gap-fill copies an embedded-only direction into a
blank sidecar field, and hand-set values keep winning through the
existing OverrideReadingDirection protection.

Tests: declared rtl/RTL/ltr, undeclared and unknown values staying
empty, plus sidecar-wins vs embedded-fills merge cases. Existing manga
EPUBs declaring rtl (verified live in-library) pick the value up on
their next scan, feeding the API and reader config mobile clients
consume.
2026-09-13 13:13:25 -04:00
John O'Keefe e944f11415 feat(scanner): fill sparse sidecars from the PDF Info dictionary
Same gap-fill rule as the EPUB path, against the PDF's embedded Info
dictionary via a readPDFInfoDict helper reusing extractPDFMetadata's
field conventions (creator falls back to author, subject maps to
description, producer to publisher, keywords to tags, plus page count).
Sidecar values always win; unopenable PDFs skip silently.

TestMergeMetadataPDFGapFill uses an Info-bearing hand-built PDF fixture
(shared with the sidecar-cover test) and asserts the sidecar title is
kept while author/description/publisher/tags/page count fill in.
2026-09-13 13:06:48 -04:00
John O'Keefe 708598687e feat(scanner): fill sparse sidecars from embedded EPUB metadata
A sparse metadata.opf/metadata.json (title only, no description) left
books thin even when the file itself carried rich data: the embedded
extractors only ran when no sidecar existed at all. Now mergeMetadata
fills blanks from the book's own OPF - title, author, description,
publisher, language, ISBN, ASIN, series/number, publish date, tags,
contributors - while sidecar values always win and unparseable files
skip silently. Also covers .kepub, which the merge previously ignored
while the extractor already supported it.

Adds TestMergeMetadataEPUBGapFill asserting both directions: sidecar
title/author survive, embedded description/publisher/language fill in.
2026-09-13 13:05:37 -04:00
John O'Keefe aac7c72900 feat(admin): archived-items page with purge dates, restore, and purge
Admins need to see what the archive lifecycle is holding: a dedicated
/admin/archived page listing every hidden item (archived or still in
the missing grace window) with library, file path, status, and - when
retention is enabled - the exact date it will be permanently deleted
(archived_at + ARCHIVE_RETENTION_DAYS).

Per row: Restore (POST /api/media-items/:id/unarchive, clears the
archive state so it reappears; if the file is still gone the next scan
hides it again) and Delete (existing DELETE endpoint for single-row
purge with its reading history). Purge All Archived reuses the existing
bulk button. The library admin banner links to the page and keeps its
purge button; row actions use data attributes with delegated listeners
in admin.ts since this templ version has no JSFunctionCall helper.

Verified live: page 200 with purge dates shown, unarchive returned 204
and reset the row, per-item delete and bulk purge ({purged:1}) both
removed their rows with no leftovers.
2026-09-13 12:01:05 -04:00
John O'Keefe fe5ab9e5f8 feat(ui): hide missing books immediately and mark offline libraries
Two visibility fixes so the UI reflects the shelf's real state on the
next scan instead of only after the archive gate:

- Missing items disappear at once: the user-facing filters already hid
  archived rows; the same listings now also require missing_scan_count =
  0. A deleted or moved-then-not-yet-repointed file vanishes from the
  UI immediately, while purge timing stays gated on archived_at plus the
  retention window - grace protects data, not visibility. Restored
  automatically when the file returns.
- Steam Deck SD-card model for unmounted storage: resolveLibrary stats
  each library's folder roots and flags libraries with no live folder
  as Offline (LibraryData gains the field, computed at request time so
  mounts/unmounts react instantly). The bookshelf shows an empty shelf
  plus a 'storage is not connected' notice for an offline selected
  library, and both the shared LibrarySwitcher and the bookshelf's
  inline select label offline libraries with their true holding counts.
  Nothing is marked or purged while offline.
- TotalMediaCount skips offline libraries, so the 'All Books/Libraries'
  totals match what is actually visible.

Verified live: renaming uploads/Manga away produced the notice, an
empty shelf, and the offline dropdown label with a corrected total;
renaming it back restored all 38 cards with zero dirty rows.
2026-09-13 12:00:49 -04:00
John O'Keefe 249b1dfe93 feat(scanner): treat reappeared content as a move, not a duplicate
When the SHA-256 dedup found identical content already in the library,
the scan skipped the file as a duplicate - and after files moved
between folders the old row kept its stale path, cycled missing ->
archived, and the new path never took. The archive feature turned the
old destructive move behavior into a stuck move instead.

Now the dedup branch stats the old location: if it is gone, the book
was MOVED, so the row is repointed (file_path, file_size) with archive
state cleared and reading history intact. 'Skip as duplicate' only
applies when the old path still exists (a true copy). Verified live: a
moved EPUB kept its single row, followed the file, and never entered
the missing/archive cycle.
2026-09-13 12:00:19 -04:00
John O'Keefe 17216e9cc7 feat(db): archive lifecycle queries, immediate-hide filters, move support
Storage behind the scan lifecycle fixes:

- MoveMediaItemFilePath: repoints a row (path, size) and clears archive
  state when identical content reappears at a new location.
- ListHiddenMediaItems: archived OR missing rows for the admin
  archived-items page; ListMediaItemsByLibraryIncludingArchived (prior
  commit) already fed the scanner.
- All user-facing listings now hide missing items immediately, not just
  archived ones: ListMediaItems, ListMediaItemsByLibrary,
  ListMediaItemsSorted, SearchMediaItems, SearchMediaItemsUnified, the
  next_books CTE, library media counts, and the five search autocomplete
  value queries gained AND mi.missing_scan_count = 0 next to the
  archived_at filter. Purge timing is unchanged (archived_at +
  ARCHIVE_RETENTION_DAYS), so the grace period keeps protecting data
  while the UI reflects removals on the first scan.
- Detail lookups by id/path/sha stay unfiltered on purpose.
2026-09-13 11:59:41 -04:00
John O'Keefe 841db91a29 fix(scanner): extract embedded covers for sidecar-managed books
A full rescan wiped cover_image_path for every PDF/EPUB living in a
metadata.json (or metadata.opf-less) folder with no cover.jpg next to
the book: the sidecar branches returned early after findSidecarCover
missed, and mergeMetadata has no PDF/EPUB cover logic of its own. Four
books lost their thumbnails while their {file}.cover.jpg files still sat
on disk - most visibly the Audiobookshelf-managed No Starch titles.

Both sidecar branches now fall through to an embedded-cover fallback
(PDF via extractPDFCover, EPUB/KEPUB via extractEPUBCover) whenever no
sidecar cover file exists. Comics are untouched: mergeMetadata already
extracts their covers from the archive.

Adds TestSidecarCoverFallback with a hand-built one-page PDF carrying a
JPEG XObject plus a metadata.json sidecar, asserting the sidecar title
wins while the cover still comes from the file. Verified live: rescans
restored all four dereferenced covers with no leftover rows.
2026-09-13 11:59:24 -04:00
John O'Keefe 1eb9c92d6a fix(scanner): nil metadata_overrides crashed Reset to Scanned with SQLSTATE 23502
Release / build-and-push (push) Successful in 2m30s
Reset to Scanned cleared the overrides row (successfully) but then set
the in-memory copy to nil before handing it to updateMediaItem. pgx
encodes a nil []string parameter as SQL NULL, so the follow-up UPDATE
wrote metadata_overrides = NULL into the column's NOT NULL constraint
and the whole rescan failed with:

  failed to update media item: ERROR: null value in column
  "metadata_overrides" of relation "media_items" violates not-null
  constraint (SQLSTATE 23502)

Two changes:

- RescanMediaItem's reset path assigns []string{} instead of nil, with a
  comment explaining the pgx nil-to-NULL encoding trap.
- updateMediaItem routes the override set through utils.MergeOverrides,
  whose contract guarantees a non-nil slice, so no caller can write
  NULL into that column again (verified against pgx v5.9.2 source: a
  scanned '{}' round-trips as non-nil in both directions; the nil could
  only come from our own assignment).

The plain Rescan path never hit this - only Reset did. Worse, the reset
is the remedy when a book's cover_image_path override pins an empty
cover, so the crash also blocked the way out of that state. After this
fix, a plain rescan on an already-reset book repopulates scanned
metadata and extracts the cover.
2026-09-13 00:12:56 -04:00
John O'Keefe 1fa8ee3a59 feat(scanner): Calibre-aligned OPF metadata extraction
Release / build-and-push (push) Successful in 3m5s
Adopt Calibre's reading conventions for the Dublin Core metadata that
parseOPFContent now pulls from the structured OPF parse:

- Titles: EPUB3 title-type selection (prefer 'main', join a distinct
  subtitle with ': ' exactly as Calibre stores it). There is no separate
  subtitle column by design - Calibre-sidecar books arrive pre-joined,
  so a column would stay empty for most libraries and force every client
  to reimplement concatenation.
- Genre: first dc:subject, mirroring the existing processGenresAndTags
  behavior of the Calibre-sidecar path; the embedded path never
  populated Genre before. Subjects stay one-element-one-tag - Library
  of Congress headings legitimately contain commas ("Holmes, Sherlock
  (Fictitious character) -- Fiction") and must not be split.
- Identifiers: urn:isbn:/urn:asin: prefixed values parse in addition to
  opf:scheme attributes, and the scheme-less fallback now requires an
  ISBN-shaped value (10/13 digits, optional separators/trailing X) so
  URIs like the Gutenberg identifiers cannot masquerade as ISBNs -
  observed live on 'A Study in Scarlet'.
- Series: EPUB3 belongs-to-collection with collection-type=series and
  group-position refines, ahead of the classic calibre:series metas.
- Audiobookshelf metadata.json sidecars join their subtitle field into
  the title the same way.

Tests cover title-type main+subtitle joining, belongs-to-collection
series with fractional group-position, urn:isbn extraction, genre/tag
parity, and comma preservation inside subject headings.
2026-09-12 23:45:18 -04:00
John O'Keefe 72d167005f fix(scanner): resolve EPUB covers via structured OPF parsing, Calibre chain
The cover lookup scraped the OPF with attribute-order-sensitive regexes.
Real books serialize attributes in any order - Grand Central's '3 Days to
Live' puts href before id on manifest items and content before name on
the cover meta - so all three regex paths missed and the book fell
through to filename guessing, extracting no cover at all. Attribute
order is meaningless in XML; the regexes were never safe.

Replace them with a structured parse (encoding/xml, namespace and
attribute-order agnostic; see the new media_scanner_opf.go) and follow
Calibre's read_raster_cover resolution order:

1. manifest item with properties=cover-image (non-(X)HTML media only)
2. <meta name=cover> resolved through the manifest, same media guard
3. first spine item that is itself a raster image (store manga)
4. NEW cover-page fallback: books declaring no raster cover at all -
   the classic EPUB2/Adobe cover.xhtml wrapper - are mined for
   <img src> / SVG <image xlink:href> references (Calibre renders the
   page with Qt; extracting the referenced image covers the practical
   cases without a rendering engine)
5. existing zip filename guessing stays as the last resort, and the old
   regex chain survives as findCoverInOPFLegacy for OPFs too malformed
   for a real XML parse.

Hrefs are now URL-decoded and posix-normalized against the OPF's own
path (path.Join semantics), so '../art/cover.jpg' from a nested cover
page and %20-encoded names resolve correctly.

Tests: attribute-order chaos modeled on the failing Patterson book,
SVG-wrapped cover pages via guide references, image-first spines, and
path resolution edge cases. Verified live against the real
'3 Days to Live' EPUB, which previously produced no cover.
2026-09-12 23:45:01 -04:00
John O'Keefe bf2c2825ac chore(deploy): document ARCHIVE_RETENTION_DAYS in compose and .env.example
Release / build-and-push (push) Successful in 2m33s
Days an archived item is kept with its reading history before library
scans purge it for good (default 90). Set 0 to keep archived items until
purged manually from the library admin page.
2026-09-12 17:50:31 -04:00
John O'Keefe cd119a74da feat(admin): purge-archived endpoint with an archived-items banner
Bulk escape hatch for archived rows (files missing from disk for 2+
scans) so a mass external deletion never has to wait out the retention
window or be clicked away row by row:

- POST /api/media-items/purge-archived (admin only) hard-deletes all
  archived items and returns the purged count; reading history goes with
  the rows, so the call is confirmed in the UI first.
- The library admin page shows an 'Archived items: N' card (only when
  non-zero) with a Purge Archived Now button that calls the endpoint,
  toasts the result, and reloads.
- Frontend admin JS exposes window.purgeArchivedItems following the
  existing localStorage-bearer-token pattern.
2026-09-12 17:50:24 -04:00
John O'Keefe 14445a7c3f feat(scanner): archive-at-two-scans lifecycle for files missing from disk
Replace the silent hard-delete orphan cleanup (which logged only through
ScannerLogger file logs and whose failure paths left rows undetected)
with an archive lifecycle that preserves reading history:

- A file missing in one scan is marked (missing_scan_count = 1); missing
  in a second consecutive scan archives it (archived_at, hidden from
  browsing, progress/notes/highlights survive). Every branch logs to
  stdout with an [ARCHIVE] prefix so skips are always visible.
- When a file reappears - same path, or identical content at a new path
  via the SHA-256 dedup match - the archived state clears automatically
  and the item returns with its history intact.
- Archived rows older than ARCHIVE_RETENTION_DAYS are hard-purged at
  scan time (cascading deletes); 0 disables auto-purge for manual-only
  management. Retention is read from the environment in NewMediaScanner.
2026-09-12 17:50:15 -04:00
John O'Keefe e6aceae0da feat(db): archive lifecycle schema and queries for missing media
Add the storage behind the archive-instead-of-delete lifecycle:

- media_items.missing_scan_count (INT, default 0) and archived_at
  (TIMESTAMPTZ, partial index), both as idempotent ADD COLUMN IF NOT
  EXISTS backfills for existing installs.
- MarkMediaItemMissing / ArchiveMediaItem / ClearMediaItemArchive plus
  PurgeExpiredArchivedMediaItems (retention cutoff) and
  PurgeAllArchivedMediaItems (manual bulk), with CountArchivedMediaItems
  for the admin UI.
- Archived items are hidden from every user-facing listing:
  ListMediaItems, ListMediaItemsByLibrary, ListMediaItemsSorted,
  SearchMediaItems, SearchMediaItemsUnified, the next_books CTE, the
  library media counts, and the search autocomplete value lists. Detail
  lookups by id/path/sha are intentionally unfiltered, and a dedicated
  ListMediaItemsByLibraryIncludingArchived feeds the scanner so the
  lifecycle pass can see and restore archived rows.
2026-09-12 17:50:01 -04:00
John O'Keefe 44b98f3fc3 feat(scanner): read Audiobookshelf metadata.json sidecars
Libraries managed by Audiobookshelf keep a metadata.json next to each
book (title, authors, series+sequence, genres/tags, publisher,
description, isbn/asin, language, published year/date) - and no
metadata.opf. The scanner silently ignored those files: deleting them
changed nothing, and their data never reached the database.

Parse them as a first-class sidecar in extractMetadata, priority
metadata.opf -> metadata.json -> embedded media. Only fields with a
matching media_items column are mapped; narrators, subtitle, explicit,
abridged, and chapters are deliberately skipped.

Cover handling is unchanged: the existing findSidecarCover priority
(cover.jpg / folder.jpg / {basename}.jpg) applies to the sidecar branch
exactly as it does for Calibre.
2026-09-12 17:49:46 -04:00
John O'Keefe 61681aac23 feat(scanner): extract EPUB metadata from the embedded OPF directly
go-epub's ReadBook parses every spine chapter and fails the entire call
if any single chapter (or the TOC) is malformed, discarding already-parsed
OPF metadata. For books like Pragmatic's 'A Common-Sense Guide' the OPF
holds good title/author/publisher/ISBN metadata that rescans then wrote
as blanks - success toast, no (visible) change.

Refactor to parse the EPUB's own OPF document with the same Dublin Core
machinery used for Calibre sidecars: parseCalibreMetadataOPF is now a thin
file wrapper around a reusable parseOPFContent([]byte), and the OPF lookup
previously inline in extractEPUBCover is shared via findOPFPathInZip. Since
metadata never touches chapter bodies, chapter damage cannot blank it.

Also picked up along the way: dc:language mapping and scheme-less
dc:identifier values that normalize to a valid ISBN (EPUB3 style).

Tests cover a Pragmatic-style EPUB (dc namespace on <metadata>, no
identifier scheme, deliberately malformed chapter) that must still yield
full metadata, plus series/date/subject OPF parsing.
2026-09-12 17:49:30 -04:00
John O'Keefe 7a9a66fcc5 feat(ui): show file path and Reset to Scanned in the metadata editor
Release / build-and-push (push) Successful in 2m24s
Complete the metadata editor modal to match the backend override work:

- File Path: read-only, monospace field showing the resolved on-disk
  location (MediaDetail.FileLocation), next to Format and File Size, so
  admins can see exactly which file backs the record without leaving the
  editor. Renders empty for non-admins, who never receive the path.
- Reset to Scanned: new button beside Rescan. It confirms, then calls
  POST /api/media-items/:id/rescan?reset_overrides=true to drop all
  per-field user overrides and re-extract scanned defaults. Rescan alone
  keeps customizations; Reset discards them.
2026-09-12 14:22:38 -04:00
John O'Keefe 2df3ecbf36 fix(ui): remove the broken Generate Cover button from the metadata editor
Generate Cover has never worked: it fetched the book file using a URL
scraped from the cover preview <img> tag (so it downloaded either the
existing cover JPEG or, when no cover existed, the detail page HTML),
then handed it to foliate-js, which rejects both. Its fixed-layout path
also called view.renderer.renderPage(), a method that does not exist in
the pinned foliate fork. Every click ended in the same generic 'Cover
generation failed' toast.

The working alternative already exists server-side: the scanner's
PDF/EPUB cover extraction plus the per-book Rescan button, now that the
rasterizer renders the CropBox. Users who want a specific image can
upload one.

Delete web/src/cover-generator.ts, the modal buttons, and the dead
generateCover()/coverGenerating/fileUrl plumbing in book-detail.ts.
2026-09-12 14:22:29 -04:00
John O'Keefe 9da193e718 feat(ui): admin-only file location and metadata editing on book detail
The book detail page exposed server internals and unusable controls to
everyday users: it now computes and renders the book's absolute on-disk
location, and gates all of it behind the admin role.

- The page handler resolves library folder + relative path (verified
  with os.Stat; falls back to the relative path when the file is not
  found on disk) into the new MediaDetail.FileLocation field - only for
  admins, so the absolute path never leaves the server for regular
  users. This also makes it possible to locate sparse entries whose
  metadata rows are largely blank.
- The Metadata grid gains a full-width, monospace, click-selectable
  Location row (admins only).
- The Edit button and the MetadataEditorModal markup render only for
  admins. The modal drives admin-only endpoints (metadata PUT, rescan,
  reset), so non-admins previously saw a button and a form that could
  only ever fail with 403s.
2026-09-12 14:21:42 -04:00
John O'Keefe 7f64b92b9d feat(metadata): per-field user overrides that survive library rescans
Previously both the metadata editor (PUT /api/media-items/:id) and the
scanner (library scans, force rescans, per-book rescans) wrote through
the same unconditional UPDATE media_items query, so any rescan wiped
user-written descriptions, tags, and uploaded covers. Custom and scanned
values were indistinguishable, and custom cover uploads even wrote to
the same {file}.cover.jpg sidecar path the scanner generates, so each
side silently clobbered the other.

Introduce metadata_overrides, a TEXT[] column on media_items listing the
column names the user has customized:

- Saving metadata records overrides per field by diffing the submitted
  values against the stored row (an untouched save records nothing);
  overrides accumulate until an explicit reset. Cover upload/removal
  always marks cover_image_path. Bulk updates mark each applied field.
- The scanner merges: updateMediaItem() now takes the existing row and
  restores every overridden column (including derived *_search arrays)
  before writing, and preserves the override set itself.
- Uploaded covers move to a dedicated {file}.custom_cover.{jpg|png|webp}
  sidecar so the scanner can never overwrite a user cover on disk.
- RescanMediaItem gains resetOverrides: POST /api/media-items/:id/rescan?
  reset_overrides=true clears the set first, returning the item to pure
  scanned defaults.

Shared detection/restore helpers live in internal/utils
(metadata_overrides.go) with unit tests covering detection, accumulation,
unset-form equality, and restore-with-derived-fields. Schema change is
an idempotent ADD COLUMN IF NOT EXISTS applied on startup. Also includes
incidental gofmt of NewMediaScanner literals in media_scanner.go.
2026-09-12 14:21:35 -04:00
John O'Keefe b9645752f5 fix(db): skip SQL comment lines when parsing schema table names
parseTableNames() regex-scanned every line of schema.sql, comments
included, with 'CREATE TABLE (?:IF NOT EXISTS )?(?:\w+\.)?(\w+)'. A doc
comment containing that phrase in prose - e.g. 'declared in CREATE TABLE
above' - registered a phantom table ('above'), and startup verification
then failed with 'missing tables: above', crash-looping the app
container on every restart.

Skip lines whose trimmed form starts with '--' so comments can never
contribute table names, and add a regression test asserting every parsed
table maps back to a real CREATE TABLE statement.
2026-09-12 14:21:28 -04:00
John O'Keefe 9c8337d0a8 fix(scanner): render the PDF CropBox, not the MediaBox, in the cover fallback
pdftoppm defaults to rasterizing the MediaBox, while PDF viewers (pdf.js
in the reader, and every other viewer) display the CropBox. For PDFs
whose page 1 is the full print cover wrap (back cover + spine + front
cover in one landscape page) with a CropBox covering only the front
cover - e.g. No Starch's XeTeX-built 'Algorithmic Thinking' - the
fallback stored the entire spread as a squashed landscape cover, while
the reader correctly showed just the front cover.

Pass -cropbox so the rendered cover always matches what the reader
displays. Poppler falls back to the MediaBox when a PDF defines no
CropBox, so PDFs with identical boxes (the common case) render exactly
as before.
2026-09-12 14:21:24 -04:00
94 changed files with 8370 additions and 2023 deletions
+3
View File
@@ -20,6 +20,9 @@ logs/
# Dependencies (install fresh in container) # Dependencies (install fresh in container)
node_modules/ node_modules/
# Local-only lockfile: a stale one pins old foliate-js commits into builds
# (npm reuses name@version without re-resolving the git pin)
package-lock.json
# Environment # Environment
.env .env
+4
View File
@@ -35,5 +35,9 @@ DBPASS=your-secure-database-password-here
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert # BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
# Conversion Cache TTL: Override default 24h # Conversion Cache TTL: Override default 24h
# BOOKHOARD_CONVERSION_CACHE_TTL=48h # BOOKHOARD_CONVERSION_CACHE_TTL=48h
# Archive Retention: days to keep archived items (files missing from disk for
# 2+ scans) before they are purged, deleting their reading history with them.
# Set 0 to keep archived items until purged manually on the library admin page.
# ARCHIVE_RETENTION_DAYS=90
# System timezone (fallback for server-side time operations, defaults to UTC) # System timezone (fallback for server-side time operations, defaults to UTC)
# TZ=America/New_York # TZ=America/New_York
+3
View File
@@ -56,6 +56,9 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
# Bake the version into the binary (Admin → Settings → About).
build-args: |
APP_VERSION=${{ env.TAG }}
# Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag. # Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag.
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml; # Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
# pin or roll back by setting IMAGE_TAG in .env. # pin or roll back by setting IMAGE_TAG in .env.
+21 -2
View File
@@ -15,7 +15,10 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
# Copy package files and install npm dependencies (cached unless package.json changes) # Copy package files and install npm dependencies (cached unless package.json changes)
COPY package*.json ./ COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \ # npm cache mount renamed from /root/.npm: the old volume held a stale
# foliate-js tarball (name@version reuse) that --no-cache and builder prune
# could not evict; a new mount id guarantees a clean fetch.
RUN --mount=type=cache,target=/root/.npm-cache2 \
npm install npm install
# Copy Go mod files (cached unless go.mod changes) # Copy Go mod files (cached unless go.mod changes)
@@ -34,10 +37,23 @@ RUN cd templates && templ generate
RUN npm run build:css:prod RUN npm run build:css:prod
RUN npm run build:ts RUN npm run build:ts
# App version baked into the binary (shown in Admin → Settings → About).
# Declared after `COPY . .` so a version bump only invalidates this layer.
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
ARG APP_VERSION=dev
# Build Go binary (cached unless Go files or generated code changes) # Build Go binary (cached unless Go files or generated code changes)
RUN --mount=type=cache,target=/root/go/pkg/mod \ RUN --mount=type=cache,target=/root/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION}" \
-o main ./cmd/server
# Materialize the Go module cache into an image layer so the test-runner
# stage can restore it. GOMODCACHE is /go/pkg/mod; cache mounts elsewhere are
# ignored by the go tool, and without this copy `go test` in the tests
# container re-downloads every dependency on each invocation.
RUN cp -r /go/pkg/mod /app/go-module-cache
# Test runner stage - includes Go runtime and test dependencies # Test runner stage - includes Go runtime and test dependencies
# This stage is ONLY used for running tests, never deployed to production # This stage is ONLY used for running tests, never deployed to production
@@ -50,6 +66,9 @@ WORKDIR /app
# Copy everything from builder (includes compiled binary, source code, and dependencies) # Copy everything from builder (includes compiled binary, source code, and dependencies)
COPY --from=builder /app ./ COPY --from=builder /app ./
# Restore the materialized Go module cache so `go test` works offline.
RUN mkdir -p /go/pkg/mod && cp -r /app/go-module-cache/. /go/pkg/mod/ && rm -rf /app/go-module-cache
# Install kepubify for conversion tests # Install kepubify for conversion tests
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \ RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
&& chmod +x /usr/bin/kepubify && chmod +x /usr/bin/kepubify
+639
View File
@@ -0,0 +1,639 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"bookhoard/internal/database"
"bookhoard/internal/services"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Cross-library SHA move detection + archived-twin merge integration tests.
// These cover the scenario where identical book content moves between
// libraries of the same type: the row must be repointed (history follows)
// instead of duplicated, deliberate copies stay independent, type rules are
// enforced, and archived twins can be merged into their active copy.
const (
moveTestAdminEmail = "testuser@tests.bookhoard.internal"
moveTestFileName = "Test Book.epub"
)
// createEbooksTestLibrary creates a library of the given type via the API and
// swaps its default folder for a fresh temp directory the test controls.
func createTypedTestLibrary(t *testing.T, setup *TestServerSetup, name, libType string) (pgtype.UUID, string) {
t.Helper()
ctx := context.Background()
libBody, _ := json.Marshal(map[string]interface{}{"name": name, "type": libType})
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(libBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusCreated, resp.StatusCode, "library creation should succeed")
var libResp map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&libResp))
libID, err := uuid.Parse(libResp["id"].(string))
require.NoError(t, err)
pgLib := pgtype.UUID{Bytes: libID, Valid: true}
folders, err := setup.DB.GetLibraryFolders(ctx, pgLib)
require.NoError(t, err)
for _, f := range folders {
_, err = setup.DB.DeleteLibraryFolder(ctx, database.DeleteLibraryFolderParams{
LibraryID: pgLib,
FolderPath: f.FolderPath,
})
require.NoError(t, err)
}
tmpDir := t.TempDir()
_, err = setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: pgLib,
FolderPath: tmpDir,
})
require.NoError(t, err)
return pgLib, tmpDir
}
// deleteLibraries removes libraries (cascading to media items, progress,
// annotations, and processing issues) so reruns never see leftovers from a
// previous run — stale rows with identical content hashes would otherwise be
// picked up as move candidates. Call via defer AFTER the setup.Close defer so
// it executes while the pool is still open (defers run LIFO).
func deleteLibraries(t *testing.T, setup *TestServerSetup, libs ...pgtype.UUID) {
t.Helper()
for _, lib := range libs {
_ = setup.DB.DeleteLibrary(context.Background(), lib)
}
}
// scanTestFolder runs a synchronous scan over one folder.
func scanTestFolder(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folder string) {
t.Helper()
scanner := services.NewMediaScanner(setup.DB)
defer func() { _ = scanner.Close() }()
scanner.SetAdminID(adminID)
require.NoError(t, scanner.SetFolders([]string{folder}, false))
require.NoError(t, scanner.ScanFolders(context.Background()))
}
// waitForCond polls cond until true or the timeout elapses.
func waitForCond(t *testing.T, timeout time.Duration, msg string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("condition not met within %v: %s", timeout, msg)
}
func moveTestAdminID(t *testing.T, setup *TestServerSetup) (pgtype.UUID, pgtype.UUID) {
t.Helper()
admin, err := setup.DB.GetUserByEmail(context.Background(), moveTestAdminEmail)
require.NoError(t, err)
pgAdmin := pgtype.UUID{Bytes: admin.ID.Bytes, Valid: true}
return pgAdmin, pgAdmin
}
// writeTestEbook places an identical dummy epub under folder/Author/.
func writeTestEbook(t *testing.T, folder string, content []byte) string {
t.Helper()
authorDir := filepath.Join(folder, "Author")
require.NoError(t, os.MkdirAll(authorDir, 0755))
bookPath := filepath.Join(authorDir, moveTestFileName)
require.NoError(t, os.WriteFile(bookPath, content, 0644))
return bookPath
}
// TestCrossLibraryMovePreservesHistory is the core scenario: a book moves
// from library A to library B (same type). The existing row must be repointed
// to library B keeping its ID so progress and annotations survive, instead of
// creating a duplicate while the old row goes missing.
func TestCrossLibraryMovePreservesHistory(t *testing.T) {
setup := setupTestServer(t)
defer func() { _ = setup.Close() }()
ctx := context.Background()
adminID, adminPgID := moveTestAdminID(t, setup)
libA, folderA := createTypedTestLibrary(t, setup, "Move Src Library", "ebooks")
libB, folderB := createTypedTestLibrary(t, setup, "Move Dst Library", "ebooks")
defer deleteLibraries(t, setup, libA, libB)
content := []byte("cross library move test epub content")
writeTestEbook(t, folderA, content)
scanTestFolder(t, setup, adminID, folderA)
var original database.ListMediaItemsByLibraryRow
waitForCond(t, 5*time.Second, "book should be imported into library A", func() bool {
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
if err != nil || len(books) != 1 {
return false
}
original = books[0]
return true
})
// Give the book real user data at its original location.
_, err := setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{
MediaItemID: original.ID,
UserID: adminPgID,
CurrentPage: pgtype.Int4{Int32: 12, Valid: true},
TotalPages: pgtype.Int4{Int32: 300, Valid: true},
})
require.NoError(t, err)
_, err = setup.DB.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
MediaItemID: original.ID,
UserID: adminPgID,
SelectionText: "a line worth keeping",
Color: pgtype.Text{String: "#ffff00", Valid: true},
})
require.NoError(t, err)
// Move the file: same bytes, new library folder, old copy deleted.
writeTestEbook(t, folderB, content)
require.NoError(t, os.Remove(filepath.Join(folderA, "Author", moveTestFileName)))
scanTestFolder(t, setup, adminID, folderB)
var moved database.ListMediaItemsByLibraryRow
waitForCond(t, 5*time.Second, "book should be repointed into library B", func() bool {
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libB)
if err != nil || len(books) != 1 {
return false
}
moved = books[0]
return true
})
assert.Equal(t, original.ID, moved.ID, "row ID must be preserved so history follows the book")
assert.Equal(t, libB, moved.LibraryID, "row must now belong to library B")
assert.Equal(t, filepath.ToSlash(filepath.Join("Author", moveTestFileName)), moved.FilePath, "path must be repointed relative to library B")
assert.False(t, moved.ArchivedAt.Valid, "moved row must not be archived")
assert.Equal(t, int32(0), moved.MissingScanCount, "moved row must not be marked missing")
assert.True(t, moved.LibraryTypeName.Valid && moved.LibraryTypeName.String == "ebooks", "library_type_name must stay truthful after library change")
// History followed the row.
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: original.ID, UserID: adminPgID})
require.NoError(t, err)
assert.Equal(t, int32(12), progress.CurrentPage.Int32, "reading progress must survive the move")
highlights, err := setup.DB.GetMediaHighlights(ctx, database.GetMediaHighlightsParams{MediaItemID: original.ID, UserID: adminPgID})
require.NoError(t, err)
assert.Len(t, highlights, 1, "annotation must survive the move")
// Library A no longer holds the row: no duplicate, no archived ghost.
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
require.NoError(t, err)
assert.Empty(t, booksA, "library A must not keep a duplicate row after the move")
}
// TestCrossLibraryCopyStaysIndependent verifies that placing identical
// content in two libraries on purpose creates two independent rows: the
// second import is NOT blocked, and neither row is repointed or archived.
func TestCrossLibraryCopyStaysIndependent(t *testing.T) {
setup := setupTestServer(t)
defer func() { _ = setup.Close() }()
ctx := context.Background()
adminID, adminPgID := moveTestAdminID(t, setup)
libA, folderA := createTypedTestLibrary(t, setup, "Copy Lib A", "ebooks")
libB, folderB := createTypedTestLibrary(t, setup, "Copy Lib B", "ebooks")
defer deleteLibraries(t, setup, libA, libB)
content := []byte("deliberate duplicate content")
writeTestEbook(t, folderA, content)
writeTestEbook(t, folderB, content)
scanTestFolder(t, setup, adminID, folderA)
scanTestFolder(t, setup, adminID, folderB)
var itemA, itemB database.ListMediaItemsByLibraryRow
waitForCond(t, 5*time.Second, "both libraries should hold their own copy", func() bool {
booksA, errA := setup.DB.ListMediaItemsByLibrary(ctx, libA)
booksB, errB := setup.DB.ListMediaItemsByLibrary(ctx, libB)
if errA != nil || errB != nil || len(booksA) != 1 || len(booksB) != 1 {
return false
}
itemA, itemB = booksA[0], booksB[0]
return true
})
assert.NotEqual(t, itemA.ID, itemB.ID, "deliberate copies must stay independent rows")
assert.False(t, itemA.ArchivedAt.Valid && itemB.ArchivedAt.Valid, "no copy should be archived")
// History on one copy does not leak into the other.
_, err := setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{
MediaItemID: itemA.ID,
UserID: adminPgID,
CurrentPage: pgtype.Int4{Int32: 7, Valid: true},
TotalPages: pgtype.Int4{Int32: 100, Valid: true},
})
require.NoError(t, err)
_, err = setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: itemB.ID, UserID: adminPgID})
assert.Error(t, err, "copy B must not inherit copy A's progress")
}
// TestCrossLibraryMoveRejectedForTypeMismatch verifies the type guard: the
// cross-library lookup only matches same-type libraries, so a reflowable EPUB
// registered in a manga library whose file moves to ANOTHER manga library is
// detected as a move but must be rejected by the type rules (manga libraries
// only accept fixed-layout/image content). The move is rejected, a processing
// issue is recorded, and the file stays untracked rather than being
// force-imported.
func TestCrossLibraryMoveRejectedForTypeMismatch(t *testing.T) {
setup := setupTestServer(t)
defer func() { _ = setup.Close() }()
ctx := context.Background()
adminID, _ := moveTestAdminID(t, setup)
libA, folderA := createTypedTestLibrary(t, setup, "Type Src Manga", "manga")
libM, folderM := createTypedTestLibrary(t, setup, "Type Dst Manga", "manga")
defer deleteLibraries(t, setup, libA, libM)
content := []byte("reflowable epub that manga libraries must not adopt via move")
writeTestEbook(t, folderA, content)
scanTestFolder(t, setup, adminID, folderA)
var original database.ListMediaItemsByLibraryRow
waitForCond(t, 5*time.Second, "book should be imported into the first manga library", func() bool {
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
if err != nil || len(books) != 1 {
return false
}
original = books[0]
return true
})
require.Equal(t, "reflowable", original.FormatGroup, "dummy epub must scan as reflowable for this test")
// Move into the second manga library: same hash, same type, but the
// reflowable format violates manga library rules.
writeTestEbook(t, folderM, content)
require.NoError(t, os.Remove(filepath.Join(folderA, "Author", moveTestFileName)))
scanTestFolder(t, setup, adminID, folderM)
// The second manga library must not adopt the item.
booksM, err := setup.DB.ListMediaItemsByLibrary(ctx, libM)
require.NoError(t, err)
assert.Empty(t, booksM, "reflowable epub must not be repointed between manga libraries")
// The original row is untouched in the first manga library.
after, err := setup.DB.GetMediaItem(ctx, original.ID)
require.NoError(t, err)
assert.Equal(t, libA, after.LibraryID, "original row must stay in its library")
// A processing issue explains the rejection.
waitForCond(t, 5*time.Second, "processing issue should be recorded", func() bool {
issues, err := setup.DB.ListProcessingIssuesByLibrary(ctx, libM)
if err != nil {
return false
}
for _, issue := range issues {
if issue.IssueType == "library_type_mismatch" {
return true
}
}
return false
})
}
// TestListHiddenMediaItemsMatchesActiveTwin verifies the archived-page query:
// an archived row with an active same-hash twin reports it in match_* columns;
// an archived row without a twin reports no match.
func TestListHiddenMediaItemsMatchesActiveTwin(t *testing.T) {
setup := setupTestServer(t)
defer func() { _ = setup.Close() }()
ctx := context.Background()
adminID, _ := moveTestAdminID(t, setup)
libA, folderA := createTypedTestLibrary(t, setup, "Twin Lib A", "ebooks")
libB, folderB := createTypedTestLibrary(t, setup, "Twin Lib B", "ebooks")
libC, folderC := createTypedTestLibrary(t, setup, "Twin Lib C", "ebooks")
defer deleteLibraries(t, setup, libA, libB, libC)
twinContent := []byte("twin content shared by two libraries")
writeTestEbook(t, folderA, twinContent)
writeTestEbook(t, folderB, twinContent)
lonelyContent := []byte("unique content with no twin anywhere")
writeTestEbook(t, folderC, lonelyContent)
scanTestFolder(t, setup, adminID, folderA)
scanTestFolder(t, setup, adminID, folderB)
scanTestFolder(t, setup, adminID, folderC)
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
require.NoError(t, err)
require.Len(t, booksA, 1)
twin := booksA[0]
booksC, err := setup.DB.ListMediaItemsByLibrary(ctx, libC)
require.NoError(t, err)
require.Len(t, booksC, 1)
lonely := booksC[0]
// Archive both: the A row now has an active twin in B, the C row has none.
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, twin.ID))
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, lonely.ID))
rows, err := setup.DB.ListHiddenMediaItems(ctx)
require.NoError(t, err)
foundTwin, foundLonely := false, false
for _, row := range rows {
if row.ID == twin.ID {
foundTwin = true
assert.True(t, row.MatchID.Valid, "twin row must report its active match")
if row.MatchID.Valid {
assert.Equal(t, findTwinTargetID(t, setup, libB), uuid.UUID(row.MatchID.Bytes).String())
}
assert.Equal(t, "Test Book", row.MatchTitle)
}
if row.ID == lonely.ID {
foundLonely = true
assert.False(t, row.MatchID.Valid, "row without a twin must report no match")
}
}
require.True(t, foundTwin, "twin row should be listed")
require.True(t, foundLonely, "lonely row should be listed")
}
// findTwinTargetID returns the single active item in the given library.
func findTwinTargetID(t *testing.T, setup *TestServerSetup, libID pgtype.UUID) string {
t.Helper()
books, err := setup.DB.ListMediaItemsByLibrary(context.Background(), libID)
require.NoError(t, err)
require.Len(t, books, 1)
return uuid.UUID(books[0].ID.Bytes).String()
}
// mergeRequest issues POST /api/media-items/{source}/merge.
func mergeRequest(t *testing.T, setup *TestServerSetup, token, sourceID string, body map[string]interface{}) *http.Response {
t.Helper()
payload, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items/"+sourceID+"/merge", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
return resp
}
// archiveAndIsolate imports one book into the library and archives it,
// simulating a file that was copied elsewhere then deleted from here.
func archiveAndIsolate(t *testing.T, setup *TestServerSetup, adminID, adminPgID pgtype.UUID, libID pgtype.UUID, folder, content string) database.ListMediaItemsByLibraryRow {
t.Helper()
ctx := context.Background()
writeTestEbook(t, folder, []byte(content))
scanTestFolder(t, setup, adminID, folder)
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libID)
require.NoError(t, err)
require.Len(t, books, 1)
item := books[0]
// Reading history that must survive the merge.
_, err = setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{
MediaItemID: item.ID,
UserID: adminPgID,
CurrentPage: pgtype.Int4{Int32: 42, Valid: true},
TotalPages: pgtype.Int4{Int32: 200, Valid: true},
})
require.NoError(t, err)
_, err = setup.DB.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
MediaItemID: item.ID,
UserID: adminPgID,
SelectionText: "annotation on the old copy",
Color: pgtype.Text{String: "#00ff00", Valid: true},
})
require.NoError(t, err)
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, item.ID))
return item
}
// TestMergeArchivedItemIntoActiveTwin covers the copy-then-delete-later
// workflow: the active copy exists in another library, the archived twin
// holds the real reading history. Merging moves progress + annotations onto
// the active row and removes the archived row.
func TestMergeArchivedItemIntoActiveTwin(t *testing.T) {
setup := setupTestServer(t)
defer func() { _ = setup.Close() }()
ctx := context.Background()
adminID, adminPgID := moveTestAdminID(t, setup)
libOld, folderOld := createTypedTestLibrary(t, setup, "Merge Old Lib", "ebooks")
libNew, folderNew := createTypedTestLibrary(t, setup, "Merge New Lib", "ebooks")
defer deleteLibraries(t, setup, libOld, libNew)
content := "merge workflow epub content"
// Old library: book + history, then archived (file "deleted").
oldItem := archiveAndIsolate(t, setup, adminID, adminPgID, libOld, folderOld, content)
// New library: the surviving active copy, no history of its own.
writeTestEbook(t, folderNew, []byte(content))
scanTestFolder(t, setup, adminID, folderNew)
booksNew, err := setup.DB.ListMediaItemsByLibrary(ctx, libNew)
require.NoError(t, err)
require.Len(t, booksNew, 1)
active := booksNew[0]
oldID := uuid.UUID(oldItem.ID.Bytes).String()
activeID := uuid.UUID(active.ID.Bytes).String()
resp := mergeRequest(t, setup, setup.Token, oldID, map[string]interface{}{"target_id": activeID})
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "merge should succeed")
var mergeResp map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&mergeResp))
// Archived row is gone.
_, err = setup.DB.GetMediaItem(ctx, oldItem.ID)
assert.Error(t, err, "archived row must be removed after merge")
// History landed on the active row.
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: active.ID, UserID: adminPgID})
require.NoError(t, err)
assert.Equal(t, int32(42), progress.CurrentPage.Int32, "progress must merge into the active copy")
highlights, err := setup.DB.GetMediaHighlights(ctx, database.GetMediaHighlightsParams{MediaItemID: active.ID, UserID: adminPgID})
require.NoError(t, err)
assert.Len(t, highlights, 1, "annotations must merge into the active copy")
booksOld, err := setup.DB.ListMediaItemsByLibrary(ctx, libOld)
require.NoError(t, err)
assert.Empty(t, booksOld, "old library must be empty after merge")
}
func TestMergeArchivedItemRejections(t *testing.T) {
setup := setupTestServer(t)
defer func() { _ = setup.Close() }()
ctx := context.Background()
adminID, adminPgID := moveTestAdminID(t, setup)
libA, folderA := createTypedTestLibrary(t, setup, "Reject Lib A", "ebooks")
libB, folderB := createTypedTestLibrary(t, setup, "Reject Lib B", "ebooks")
defer deleteLibraries(t, setup, libA, libB)
t.Run("requires admin", func(t *testing.T) {
writeTestEbook(t, folderA, []byte("admin check content"))
scanTestFolder(t, setup, adminID, folderA)
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
require.NoError(t, err)
require.Len(t, booksA, 1)
itemID := uuid.UUID(booksA[0].ID.Bytes).String()
resp := mergeRequest(t, setup, setup.RegularToken, itemID, map[string]interface{}{"target_id": itemID})
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("rejects active source", func(t *testing.T) {
writeTestEbook(t, folderA, []byte("active source content"))
scanTestFolder(t, setup, adminID, folderA)
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
require.NoError(t, err)
require.Len(t, booksA, 1)
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
writeTestEbook(t, folderB, []byte("active source content"))
scanTestFolder(t, setup, adminID, folderB)
targetID := findTwinTargetID(t, setup, libB)
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID})
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "source is not archived/missing")
})
t.Run("rejects hash mismatch", func(t *testing.T) {
writeTestEbook(t, folderA, []byte("hash mismatch content A"))
scanTestFolder(t, setup, adminID, folderA)
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
require.NoError(t, err)
require.Len(t, booksA, 1)
source := booksA[0]
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, source.ID))
sourceID := uuid.UUID(source.ID.Bytes).String()
writeTestEbook(t, folderB, []byte("hash mismatch content B - different bytes"))
scanTestFolder(t, setup, adminID, folderB)
targetID := findTwinTargetID(t, setup, libB)
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID})
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "different content must not merge")
// Both rows still exist.
_, err = setup.DB.GetMediaItem(ctx, source.ID)
assert.NoError(t, err, "source must survive a rejected merge")
})
t.Run("requires target_id", func(t *testing.T) {
writeTestEbook(t, folderA, []byte("missing target content"))
scanTestFolder(t, setup, adminID, folderA)
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
require.NoError(t, err)
require.Len(t, booksA, 1)
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID))
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{})
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("rejects self merge", func(t *testing.T) {
writeTestEbook(t, folderA, []byte("self merge content"))
scanTestFolder(t, setup, adminID, folderA)
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
require.NoError(t, err)
require.Len(t, booksA, 1)
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID))
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": sourceID})
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("rejects archived target", func(t *testing.T) {
content := []byte("archived target content")
writeTestEbook(t, folderA, content)
scanTestFolder(t, setup, adminID, folderA)
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
require.NoError(t, err)
require.Len(t, booksA, 1)
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID))
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
writeTestEbook(t, folderB, content)
scanTestFolder(t, setup, adminID, folderB)
booksB, err := setup.DB.ListMediaItemsByLibrary(ctx, libB)
require.NoError(t, err)
require.Len(t, booksB, 1)
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksB[0].ID))
targetID := uuid.UUID(booksB[0].ID.Bytes).String()
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID})
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "target must be active")
})
_ = adminPgID
}
// TestLibraryTypeNameTriggerOnLibraryChange proves the schema trigger keeps
// media_items.library_type_name in sync when a row is repointed to a library
// of a different type (the INSERT-only trigger previously left it stale).
func TestLibraryTypeNameTriggerOnLibraryChange(t *testing.T) {
setup := setupTestServer(t)
defer func() { _ = setup.Close() }()
ctx := context.Background()
adminID, _ := moveTestAdminID(t, setup)
libE, folderE := createTypedTestLibrary(t, setup, "Trigger Ebooks", "ebooks")
libC, _ := createTypedTestLibrary(t, setup, "Trigger Comics", "comics")
defer deleteLibraries(t, setup, libE, libC)
writeTestEbook(t, folderE, []byte("trigger check content"))
scanTestFolder(t, setup, adminID, folderE)
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libE)
require.NoError(t, err)
require.Len(t, books, 1)
item := books[0]
require.True(t, item.LibraryTypeName.Valid && item.LibraryTypeName.String == "ebooks")
require.NoError(t, setup.DB.MoveMediaItemToLibrary(ctx, database.MoveMediaItemToLibraryParams{
ID: item.ID,
LibraryID: libC,
FilePath: "Author/Test Book.epub",
FileSize: pgtype.Int8{Int64: 27, Valid: true},
}))
after, err := setup.DB.GetMediaItem(ctx, item.ID)
require.NoError(t, err)
assert.Equal(t, libC, after.LibraryID)
assert.True(t, after.LibraryTypeName.Valid && after.LibraryTypeName.String == "comics",
"trigger must refresh library_type_name when library_id changes")
}
+82
View File
@@ -252,6 +252,88 @@ func TestListPendingRegistrations(t *testing.T) {
assert.NotNil(t, pending, "Pending registrations should not be nil") assert.NotNil(t, pending, "Pending registrations should not be nil")
} }
// Regression test for the "approve returns 500 after app reinstall" bug:
// an app reinstall that preserves data (e.g. Android Studio installDebug
// over an existing install) re-registers with the SAME device_identifier,
// and the blind INSERT in ApproveDevice hit the UNIQUE(device_identifier)
// constraint. Re-approval must be idempotent: same row (id unchanged),
// rotated token, old token invalidated.
func TestApproveDeviceReapprovalRotatesToken(t *testing.T) {
setup := setupTestServer(t)
userID := getTestUserID(t, setup.DB)
identifier := fmt.Sprintf("repro-reinstall-%s", uuid.New().String())
registerAndApprove := func() string {
regRequest := map[string]interface{}{
"device_name": "Reinstall Device",
"device_type": "mobile",
"device_identifier": identifier,
}
regBody, _ := json.Marshal(regRequest)
req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate registration")
var regResponse map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &regResponse)
registrationID, ok := regResponse["registration_id"].(string)
require.True(t, ok, "Should have registration_id")
req = httptest.NewRequest("GET", fmt.Sprintf("/api/devices/approve/%s", registrationID), nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec = httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code,
"Approve must succeed even when the identifier already exists (was the reinstall 500)")
// The status response is single-use and carries the credentials.
statusBody, _ := json.Marshal(map[string]string{"registration_id": registrationID})
req = httptest.NewRequest("POST", "/api/devices/register/status", bytes.NewReader(statusBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var statusResponse map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &statusResponse)
require.Equal(t, "approved", statusResponse["status"])
token, ok := statusResponse["auth_token"].(string)
require.True(t, ok, "Should have auth_token")
require.NotEmpty(t, token)
return token
}
token1 := registerAndApprove()
token2 := registerAndApprove()
assert.NotEqual(t, token1, token2, "Re-approval must rotate the auth token (fresh install = fresh credentials)")
devices, err := setup.DB.ListDevicesByUser(context.Background(),
pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
require.NoError(t, err)
rows := 0
for _, d := range devices {
if d.DeviceIdentifier == identifier {
rows++
}
}
assert.Equal(t, 1, rows, "Re-approval must reuse the existing devices row, not duplicate it")
// The rotated-in token must be the live one.
var live *database.Devices
for i := range devices {
if devices[i].DeviceIdentifier == identifier {
live = &devices[i]
}
}
require.NotNil(t, live)
assert.Equal(t, token2, live.AuthToken, "The devices row must carry the newly rotated token")
}
func TestApproveDeviceRegistration(t *testing.T) { func TestApproveDeviceRegistration(t *testing.T) {
setup := setupTestServer(t) setup := setupTestServer(t)
+113
View File
@@ -6,6 +6,7 @@ import (
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -435,3 +436,115 @@ func TestOPDSSearchAcrossLibraries(t *testing.T) {
t.Logf("❌ FAILED - OPDS returned %d", resp.StatusCode) t.Logf("❌ FAILED - OPDS returned %d", resp.StatusCode)
} }
} }
// TestOPDSLibraryFolders covers the library-folder navigation feed: the root
// catalog is a navigation feed with an "All Books" entry plus one folder per
// visible library, the scoped catalog only lists that library's books, and
// the library-scoped search parameter restricts results to that library.
// TestOPDSLibraryFolders covers the library-folder navigation feed: the root
// catalog is a navigation feed with an "All Books" entry plus one folder per
// visible library, the scoped catalog only lists that library's books, and
// the library-scoped search parameter restricts results to that library.
// NOTE: library names deliberately avoid the word "test" — setupTestServer's
// setup-time cleanup deletes every library whose name contains it (and
// setupDeviceTest re-runs that cleanup mid-test).
func TestOPDSLibraryFolders(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
// Two libraries, one distinct book each.
lib1Resp := createLibrary(t, client, setup, "OPDS Folder Lib Alpha")
lib2Resp := createLibrary(t, client, setup, "OPDS Folder Lib Beta")
lib1ID := lib1Resp["id"].(string)
lib2ID := lib2Resp["id"].(string)
// Real directories: the folder API validates that the path exists on the
// host running the server (container path /app/uploads won't exist when
// tests run on the host).
addFolderToLibrary(t, setup, lib1ID, t.TempDir())
addFolderToLibrary(t, setup, lib2ID, t.TempDir())
createTestMediaItemIDInLibrary(t, client, setup, lib1ID, "OPDS Folder Book 1")
createTestMediaItemIDInLibrary(t, client, setup, lib2ID, "OPDS Folder Book 2")
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "OPDS Folders Device", "koreader", "opds-folders-"+uuid.NewString())
base := setup.Server.URL + "/opds/devices/" + device.ID.String()
// Explicit cleanup: these names don't contain "test", so the harness
// cleanup skips them; remove them ourselves. Registered as a defer AFTER
// the setup.Close defer so LIFO ordering runs it while the pool is open.
defer deleteLibraries(t, setup,
pgtype.UUID{Bytes: uuid.MustParse(lib1ID), Valid: true},
pgtype.UUID{Bytes: uuid.MustParse(lib2ID), Valid: true})
getFeed := func(t *testing.T, url string) (int, string, string) {
t.Helper()
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
contentType := resp.Header.Get("Content-Type")
bodyBytes, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, contentType, string(bodyBytes)
}
t.Run("root catalog is a navigation feed with All Books and library folders", func(t *testing.T) {
status, contentType, body := getFeed(t, base+"/catalog")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, contentType, "kind=navigation")
assert.Contains(t, body, "All Books")
assert.Contains(t, body, "OPDS Folder Lib Alpha")
assert.Contains(t, body, "OPDS Folder Lib Beta")
assert.Contains(t, body, "/library/"+lib1ID+"/catalog")
assert.Contains(t, body, "/library/"+lib2ID+"/catalog")
// The nav feed itself must not inline book entries.
assert.NotContains(t, body, "OPDS Folder Book 1")
})
t.Run("all=1 keeps the flat cross-library catalog", func(t *testing.T) {
status, contentType, body := getFeed(t, base+"/catalog?all=1")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, contentType, "kind=acquisition")
assert.Contains(t, body, "OPDS Folder Book 1")
assert.Contains(t, body, "OPDS Folder Book 2")
})
t.Run("scoped catalog lists only its own library's books", func(t *testing.T) {
status, contentType, body := getFeed(t, base+"/library/"+lib1ID+"/catalog")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, contentType, "kind=acquisition")
assert.Contains(t, body, "OPDS Folder Book 1")
assert.NotContains(t, body, "OPDS Folder Book 2")
// Scoped search discovery: template pins the library.
assert.Contains(t, body, "library_id="+lib1ID)
})
t.Run("scoped catalog rejects libraries outside device visibility", func(t *testing.T) {
status, _, _ := getFeed(t, base+"/library/11111111-2222-3333-4444-555555555555/catalog")
assert.Equal(t, http.StatusForbidden, status)
})
t.Run("scoped search restricts results to the library", func(t *testing.T) {
status, _, body := getFeed(t, base+"/search?q=OPDS+Folder&library_id="+lib2ID)
require.Equal(t, http.StatusOK, status)
assert.Contains(t, body, "OPDS Folder Book 2")
assert.NotContains(t, body, "OPDS Folder Book 1")
})
t.Run("scoped search rejects inaccessible library", func(t *testing.T) {
status, _, _ := getFeed(t, base+"/search?q=OPDS&library_id=11111111-2222-3333-4444-555555555555")
assert.Equal(t, http.StatusForbidden, status)
})
t.Run("global search labels entries with their library", func(t *testing.T) {
status, _, body := getFeed(t, base+"/search?q=OPDS+Folder")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, body, "OPDS Folder Book 1")
assert.Contains(t, body, "OPDS Folder Book 2")
assert.Contains(t, body, "OPDS Folder Lib Alpha")
assert.Contains(t, body, "OPDS Folder Lib Beta")
})
}
@@ -0,0 +1,53 @@
package main
import (
"context"
"testing"
"bookhoard/internal/config"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
)
// TestSchemaInitializationIsIdempotent replays the application's production
// schema initializer twice against the same database. schema.sql runs on
// EVERY app startup, so it must apply cleanly to a database where a previous
// startup already applied it. A regression here crash-loops the production
// container on its second boot (e.g. SQLSTATE 2BP01: DROP FUNCTION refused
// while a trigger created by the previous run still references it).
//
// Uses a dedicated pool: production Initialize holds the advisory lock on one
// connection while executing the schema on another, but the shared test pool
// is capped at MaxConns=1 and would deadlock.
func TestSchemaInitializationIsIdempotent(t *testing.T) {
if testing.Short() {
t.Skip("skipping schema re-initialization in -short mode")
}
// Bring up the standard test environment (seeds users, starts server).
setup := setupTestServer(t)
defer func() { _ = setup.Close() }()
// Dedicated pool with default connection limits for the initializer.
poolCfg, err := pgxpool.ParseConfig(config.LoadConfig().DatabaseURL())
require.NoError(t, err, "failed to parse database URL")
pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg)
require.NoError(t, err, "failed to connect for schema initialization test")
defer func() { pool.Close() }()
ctx := context.Background()
// First boot: applies the schema to the (possibly fresh) database.
if err := database.Initialize(ctx, pool); err != nil {
t.Fatalf("first schema initialization failed: %v", err)
}
// Second boot against the now-initialized database: this is exactly
// where non-idempotent statements (bare CREATE TRIGGER, DROP FUNCTION
// with dependent objects, etc.) blow up.
if err := database.Initialize(ctx, pool); err != nil {
t.Fatalf("second schema initialization failed: %v — schema.sql must stay idempotent across restarts", err)
}
}
+38 -14
View File
@@ -544,6 +544,10 @@ func setupTestServer(t *testing.T) *TestServerSetup {
AnnotationService: annotationService, AnnotationService: annotationService,
DeviceAuthMiddleware: deviceAuthMiddleware, DeviceAuthMiddleware: deviceAuthMiddleware,
LoginTracker: loginAttemptTracker, LoginTracker: loginAttemptTracker,
// RegisterRoutes dereferences Settings for the auth rate limit;
// the main binary wires database.NewSettingsRegistry(queries) —
// the test harness must too or route registration nil-panics.
Settings: database.NewSettingsRegistry(queries),
} }
router.RegisterRoutes(routerConfig) router.RegisterRoutes(routerConfig)
@@ -588,21 +592,41 @@ func setupTestServer(t *testing.T) *TestServerSetup {
} }
} }
// Create fresh admin test user // The admin test user doubles as the shared dev admin and is
// deliberately PRESERVED by the cleanup above — reuse it when it
// already exists instead of re-creating (a blind INSERT hits
// users_email_key on every run after the first).
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq" passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
adminUser, err := queries.CreateUser(ctx, database.CreateUserParams{ var adminUUID uuid.UUID
Email: "testuser@tests.bookhoard.internal", if existing, gerr := queries.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal"); gerr == nil {
Username: "testuser", adminUUID, err = uuid.FromBytes(existing.ID.Bytes[:])
PasswordHash: passwordHash, require.NoError(t, err, "Failed to parse admin user UUID")
FirstName: pgtype.Text{String: "Test", Valid: true}, // Reset the password to the known test constant: other tests (e.g.
LastName: pgtype.Text{String: "User", Valid: true}, // password-change tests) mutate the shared admin's password, which
Role: "admin", // would otherwise lock every later test out with login 401s until
}) // the database is wiped.
require.NoError(t, err, "Failed to create admin test user") if updErr := queries.UpdatePassword(ctx, database.UpdatePasswordParams{
ID: existing.ID,
adminUUID, err := uuid.FromBytes(adminUser.ID.Bytes[:]) PasswordHash: passwordHash,
require.NoError(t, err, "Failed to parse admin user UUID") }); updErr != nil {
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true}) require.NoError(t, updErr, "Failed to reset shared admin test password")
}
} else {
adminUser, cerr := queries.CreateUser(ctx, database.CreateUserParams{
Email: "testuser@tests.bookhoard.internal",
Username: "testuser",
PasswordHash: passwordHash,
FirstName: pgtype.Text{String: "Test", Valid: true},
LastName: pgtype.Text{String: "User", Valid: true},
Role: "admin",
})
require.NoError(t, cerr, "Failed to create admin test user")
adminUUID, err = uuid.FromBytes(adminUser.ID.Bytes[:])
require.NoError(t, err, "Failed to parse admin user UUID")
// Default collections are created exactly once per user; the
// preserved dev admin already has its set from the first run.
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
}
// Create fresh regular test user // Create fresh regular test user
regularUser, err := queries.CreateUser(ctx, database.CreateUserParams{ regularUser, err := queries.CreateUser(ctx, database.CreateUserParams{
+35 -1
View File
@@ -207,6 +207,19 @@ CREATE TABLE IF NOT EXISTS media_items (
-- Summary (distinct from description - may merge with Calibre description) -- Summary (distinct from description - may merge with Calibre description)
summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre) summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre)
-- Per-field user-override tracking. Column names listed here (e.g.
-- 'description', 'tags', 'cover_image_path') are user-customized and MUST
-- NOT be overwritten by library scans or per-book rescans; only the
-- reset-to-scanned-defaults action clears them.
metadata_overrides TEXT[] NOT NULL DEFAULT '{}',
-- Archive lifecycle: a file missing from disk for two consecutive scans
-- gets archived_at set (hidden from all browsing, reading history kept).
-- If the file returns, the row is un-archived. Archived rows are purged
-- after ARCHIVE_RETENTION_DAYS (0 = manual purge only).
missing_scan_count INT NOT NULL DEFAULT 0,
archived_at TIMESTAMPTZ,
-- Chapter metadata for reader navigation and progress tracking -- Chapter metadata for reader navigation and progress tracking
-- Caches detected chapter structure to avoid re-parsing files -- Caches detected chapter structure to avoid re-parsing files
-- Populated by ReaderService.DetectChapters() on first read -- Populated by ReaderService.DetectChapters() on first read
@@ -232,12 +245,25 @@ 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 tags_search TEXT[];
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[]; ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
-- Per-field user-override tracking (idempotent backfill for existing
-- installs; the column is also declared in the media_items table
-- definition above for fresh databases)
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS metadata_overrides TEXT[] NOT NULL DEFAULT '{}';
-- Archive lifecycle (idempotent backfill for existing installs)
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS missing_scan_count INT NOT NULL DEFAULT 0;
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_media_items_archived ON media_items (archived_at) WHERE archived_at IS NOT NULL;
-- Create GIN indexes for fast search field searches -- Create GIN indexes for fast search field searches
CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search); CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search);
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search); CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search);
-- Drop existing trigger if exists -- Drop existing triggers if exists. Both must be dropped BEFORE the function:
-- dropping a function that triggers still reference fails (SQLSTATE 2BP01),
-- which would abort the schema transaction on every startup after the first.
DROP TRIGGER IF EXISTS set_library_type_name_on_insert ON media_items; DROP TRIGGER IF EXISTS set_library_type_name_on_insert ON media_items;
DROP TRIGGER IF EXISTS set_library_type_name_on_library_change ON media_items;
-- Drop existing function if exists -- Drop existing function if exists
DROP FUNCTION IF EXISTS set_library_type_name(); DROP FUNCTION IF EXISTS set_library_type_name();
@@ -256,6 +282,14 @@ CREATE TRIGGER set_library_type_name_on_insert
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION set_library_type_name(); EXECUTE FUNCTION set_library_type_name();
-- Keep library_type_name truthful when an item is repointed to another
-- library (cross-library move detection reuses the same function; the row
-- keeps its ID while its library changes).
CREATE TRIGGER set_library_type_name_on_library_change
BEFORE UPDATE OF library_id ON media_items
FOR EACH ROW
EXECUTE FUNCTION set_library_type_name();
-- Add GIN indexes for pg_trgm fuzzy search performance -- Add GIN indexes for pg_trgm fuzzy search performance
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
ON media_items USING GIN (author gin_trgm_ops); ON media_items USING GIN (author gin_trgm_ops);
+6
View File
@@ -61,6 +61,12 @@ services:
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify} BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h} BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
# Library Maintenance
# Days an archived item (file missing from disk for 2+ scans) is kept,
# with its reading history, before library scans purge it for good.
# Set 0 to keep archived items until purged manually on the library admin page.
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-90}
# System timezone (fallback for server-side time operations) # System timezone (fallback for server-side time operations)
TZ: ${TZ:-UTC} TZ: ${TZ:-UTC}
ports: ports:
+2
View File
@@ -77,6 +77,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) | | **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) | | **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) | | **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
| **Read in the browser** | [User Portal → Web Reader Guide](user/reader-guide.md) |
--- ---
@@ -90,6 +91,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) | | ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) |
| ...use a Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) - native sync coming soon; KOReader works today | | ...use a Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) - native sync coming soon; KOReader works today |
| ...understand sync? | [Sync Guide](user/sync-guide.md) | | ...understand sync? | [Sync Guide](user/sync-guide.md) |
| ...read in the browser? | [Web Reader Guide](user/reader-guide.md) - reading, highlights, notes |
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts | | ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) | | ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
| ...use the API? | [API Reference](developer/api-reference.md) | | ...use the API? | [API Reference](developer/api-reference.md) |
+81
View File
@@ -0,0 +1,81 @@
# Web Reader Guide
Bookhoard's built-in web reader works in any modern browser — desktop or
mobile — and keeps your position, highlights, bookmarks, and notes in sync
with the Android app and KOReader.
## Opening a Book
From your library or book page, click **Read**. The reader opens in an
immersive view with the book's cover, title, and chapter in the top bar
(EPUB) or page number (PDF/comics).
## Navigation
### On a phone or tablet (touch)
| Gesture | Action |
|---|---|
| **Swipe** left / right | Turn the page |
| **Tap** once | Toggle the reader menu (headers, settings, drawers) |
| **Tap** in a corner zone | With the menu open: jump back / forward by chapter |
Paging is swipe-only by design: taps are reserved for the menu so a page
turn can never fire while you're trying to select text.
### On desktop
- Scroll wheel, trackpad, arrow keys, or **Page Up / Page Down** to page
- Click the progress bar (bottom, with the menu open) to jump
- Contents drawer for the table of contents; Pages grid for PDF/comics
## Text Selection & Highlights
### Selecting text (touch)
1. **Press and hold** a word (~half a second) until it highlights
2. **Drag** to extend the selection word by word — it stays on the
visible page
3. **Drag the round handles** (EPUB) to fine-tune the start and end
points
4. Lift your finger and pause — the highlight menu appears **below** the
selection, underneath the browser's own copy menu
The browser's address bar hides while a selection is active to give the
page room; it returns as soon as the selection is cleared.
### Creating a highlight or note
With the selection menu open:
- **Color dots** — tap one to highlight immediately in that color
- **Pencil** — opens a note; write it and tap **Highlight with note**
to save the highlight and its note together
- **Copy** — copies the selected text (works even over plain HTTP on
your LAN)
- While the note editor is open, you can still change the color — the
popover stays put until you save or cancel
### Editing or removing a highlight
Tap anywhere on a painted highlight to reopen it in the menu. From there
you can change its color, edit the note (pencil → **Save note**), or
delete it (trash icon). Tapping anywhere else closes the menu.
### PDFs and comics
Text selection works the same way on PDFs. Highlights are stored as
page-anchored rectangles and sync to the Android app. Comics/manga
libraries disable annotation (bookmarks still work).
## Notes & Bookmarks
The **annotations drawer** (right side, from the open menu) lists your
highlights, standalone notes, and bookmarks for the book — tap any entry
to jump to it.
## Where Reading Position Is Stored
Position, highlights, bookmarks, and notes sync automatically through the
server (see the [Sync Guide](sync-guide.md)). Close the tab whenever you
like — the reader resumes exactly where you left off on any device.
+8
View File
@@ -36,6 +36,14 @@ Learn how to configure your e-reader devices to sync with Bookhoard:
- Troubleshooting - Troubleshooting
- Best practices - Best practices
## 📖 Reading
- **[Web Reader Guide](reader-guide.md)** - Reading and annotating in the browser
- Touch navigation (swipe to page, tap for menu)
- Selecting text: long-press, drag, handles
- Highlights, notes, copy, and editing
- PDFs and comics
## 🎨 Frontend Guide ## 🎨 Frontend Guide
**[Frontend Guide](frontend-guide.md)** - Learn how to use the Bookhoard web interface **[Frontend Guide](frontend-guide.md)** - Learn how to use the Bookhoard web interface
+26
View File
@@ -1,5 +1,7 @@
github.com/ArcadiaLin/go-epub v0.1.1 h1:13roe62tarrZ1Y1QTxE+Bzd/NKlChhRzegLVmU5Hgws= github.com/ArcadiaLin/go-epub v0.1.1 h1:13roe62tarrZ1Y1QTxE+Bzd/NKlChhRzegLVmU5Hgws=
github.com/ArcadiaLin/go-epub v0.1.1/go.mod h1:GY09AG6jnEbsYytkw6VeICLOt25F1GQDGXjYS7cxNhU= github.com/ArcadiaLin/go-epub v0.1.1/go.mod h1:GY09AG6jnEbsYytkw6VeICLOt25F1GQDGXjYS7cxNhU=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
@@ -12,8 +14,13 @@ github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -22,6 +29,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
@@ -72,16 +81,23 @@ github.com/labstack/echo/v5 v5.1.0 h1:MvIRydoN+p9cx/zq8Lff6YXqUW2ZaEsOMISzEGSMrB
github.com/labstack/echo/v5 v5.1.0/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= github.com/labstack/echo/v5 v5.1.0/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas= github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas=
github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw= github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -91,6 +107,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -98,6 +116,8 @@ github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -118,16 +138,22 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+11
View File
@@ -84,3 +84,14 @@ func getEnvInt(key string, defaultValue int) int {
} }
return defaultValue return defaultValue
} }
// ArchiveRetentionDays returns how many days a media item stays archived
// (file missing from disk for two consecutive scans) before library scans
// purge it for good. Reading progress, notes, and highlights survive the
// archive window and are restored if the file returns; the purge deletes
// them along with the row.
// Configure via ARCHIVE_RETENTION_DAYS (default 90); 0 keeps archived items
// until an admin purges them manually from the library admin page.
func ArchiveRetentionDays() int {
return getEnvInt("ARCHIVE_RETENTION_DAYS", 90)
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.31.1
package database package database
+13 -10
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.31.1
package database package database
@@ -305,15 +305,18 @@ type MediaItems struct {
// Scan information from ComicInfo.xml (scanner group, resolution, etc.) // Scan information from ComicInfo.xml (scanner group, resolution, etc.)
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"` ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
// Summary from ComicInfo.xml (may be merged with description from Calibre) // Summary from ComicInfo.xml (may be merged with description from Calibre)
Summary pgtype.Text `db:"summary" json:"summary"` Summary pgtype.Text `db:"summary" json:"summary"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
TagsSearch []string `db:"tags_search" json:"tags_search"` ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` TagsSearch []string `db:"tags_search" json:"tags_search"`
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
} }
type MediaNotes struct { type MediaNotes struct {
+41 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.31.1
package database package database
@@ -20,6 +20,9 @@ type Querier interface {
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error) AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
// Library Folders queries // Library Folders queries
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error) AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
// Second consecutive missing scan: hide the item from all browsing while
// preserving reading history in case the file returns.
ArchiveMediaItem(ctx context.Context, id pgtype.UUID) error
// Bulk update format group for all media items // Bulk update format group for all media items
BulkUpdateFormatGroups(ctx context.Context) error BulkUpdateFormatGroups(ctx context.Context) error
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error) BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
@@ -30,7 +33,14 @@ type Querier interface {
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
// File is back on disk (by path or content hash): restore visibility and
// reset the missing-scan counter. No-op for items that were never archived.
ClearMediaItemArchive(ctx context.Context, id pgtype.UUID) error
// Reset an item to scanned defaults: clears per-field user overrides so the
// next rescan can freely overwrite user-customized metadata.
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
CountAdmins(ctx context.Context) (int64, error) CountAdmins(ctx context.Context) (int64, error)
CountArchivedMediaItems(ctx context.Context) (int64, error)
// Count unlinked books for a device // Count unlinked books for a device
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
@@ -249,6 +259,12 @@ type Querier interface {
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error) GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
// Per-item user-data counts, used when choosing which duplicate copy to keep // Per-item user-data counts, used when choosing which duplicate copy to keep
GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtype.UUID) (GetMediaItemUsageCountsRow, error) GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtype.UUID) (GetMediaItemUsageCountsRow, error)
// Cross-library move detection: identical content registered in a different
// library of the same type (ebooks stay with ebooks, comics with comics).
// Candidates may be active copies (deliberate duplicates - leave alone) or
// hidden/archived rows whose file vanished (move candidates - the scanner
// verifies disk state before repointing).
GetMediaItemsBySHA256AndSameLibraryType(ctx context.Context, arg GetMediaItemsBySHA256AndSameLibraryTypeParams) ([]GetMediaItemsBySHA256AndSameLibraryTypeRow, error)
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error) GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
// ============================================ // ============================================
// ANNOTATION SYNC QUERIES (notes) // ANNOTATION SYNC QUERIES (notes)
@@ -337,9 +353,17 @@ type Querier interface {
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error) ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
// Items hidden from libraries: either missing from disk (waiting on the
// second scan that archives them) or already archived and pending purge.
// Feeds the admin archived-items page. match_* columns identify an active
// item with identical content (same SHA-256) so the UI can offer merging the
// 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)
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
// List all media items sharing a SHA-256 hash within a library (hash conflict group) // List all media items sharing a SHA-256 hash within a library (hash conflict group)
ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error) ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error)
// List media items that have no stored SHA-256 (imported before hashing existed) // List media items that have no stored SHA-256 (imported before hashing existed)
@@ -353,6 +377,22 @@ type Querier interface {
// List unresolved unlinked books with pagination // List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk.
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
// Content reappeared at a new location while the old path vanished: treat as
// a move. Repoints the row (and refreshes file-level fields) so reading
// history follows the book; archive/missing state is cleared separately.
MoveMediaItemFilePath(ctx context.Context, arg MoveMediaItemFilePathParams) error
// Cross-library move: identical content reappeared in a different library of
// the same type while the old file vanished. Repoints the row so reading
// history, annotations and collections follow the book; archive/missing
// state is cleared. library_type_name is refreshed by trigger.
MoveMediaItemToLibrary(ctx context.Context, arg MoveMediaItemToLibraryParams) error
// Manual bulk purge from the library admin page.
PurgeAllArchivedMediaItems(ctx context.Context) ([]PurgeAllArchivedMediaItemsRow, error)
// Retention sweep at library-scan time: hard-delete archived items older
// than the cutoff. Cascades remove reading history with the row.
PurgeExpiredArchivedMediaItems(ctx context.Context, archivedAt pgtype.Timestamptz) ([]PurgeExpiredArchivedMediaItemsRow, error)
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
File diff suppressed because it is too large Load Diff
+159 -3
View File
@@ -141,7 +141,9 @@ ORDER BY l.created_at ASC;
SELECT l.id, COUNT(mi.id) as media_count SELECT l.id, COUNT(mi.id) as media_count
FROM libraries l FROM libraries l
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
LEFT JOIN media_items mi ON mi.library_id = l.id LEFT JOIN media_items mi ON mi.library_id = l.id AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.missing_scan_count = 0
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
GROUP BY l.id; GROUP BY l.id;
@@ -160,6 +162,8 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
FROM media_items mi FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2; ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
-- name: ListMediaItemsByLibrary :many -- name: ListMediaItemsByLibrary :many
@@ -167,6 +171,16 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
FROM media_items mi FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.library_id = $1
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.created_at DESC;
-- name: ListMediaItemsByLibraryIncludingArchived :many
SELECT mi.*, l.name as library_name, lt.name as library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.library_id = $1 WHERE mi.library_id = $1
ORDER BY mi.created_at DESC; ORDER BY mi.created_at DESC;
@@ -175,7 +189,9 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
FROM media_items mi FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id JOIN library_types lt ON l.library_type_id = lt.id
WHERE mi.library_id = sqlc.narg('library_id') WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
ORDER BY ORDER BY
CASE CASE
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
@@ -282,10 +298,104 @@ UPDATE media_items SET
alternate_info = $35, alternate_info = $35,
scan_information = $36, scan_information = $36,
summary = $37, summary = $37,
metadata_overrides = $38,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
-- name: ClearMediaItemMetadataOverrides :exec
-- Reset an item to scanned defaults: clears per-field user overrides so the
-- next rescan can freely overwrite user-customized metadata.
UPDATE media_items SET metadata_overrides = '{}', updated_at = NOW()
WHERE id = $1;
-- name: MarkMediaItemMissing :exec
-- First consecutive scan that cannot find the file on disk.
UPDATE media_items SET missing_scan_count = missing_scan_count + 1, updated_at = NOW()
WHERE id = $1;
-- name: MoveMediaItemFilePath :exec
-- Content reappeared at a new location while the old path vanished: treat as
-- a move. Repoints the row (and refreshes file-level fields) so reading
-- history follows the book; archive/missing state is cleared separately.
UPDATE media_items SET file_path = $2, file_size = $3, missing_scan_count = 0,
archived_at = NULL, updated_at = NOW()
WHERE id = $1;
-- name: ArchiveMediaItem :exec
-- Second consecutive missing scan: hide the item from all browsing while
-- preserving reading history in case the file returns.
UPDATE media_items SET archived_at = NOW(), updated_at = NOW()
WHERE id = $1;
-- name: MoveMediaItemToLibrary :exec
-- Cross-library move: identical content reappeared in a different library of
-- the same type while the old file vanished. Repoints the row so reading
-- history, annotations and collections follow the book; archive/missing
-- state is cleared. library_type_name is refreshed by trigger.
UPDATE media_items
SET library_id = $2,
file_path = $3,
file_size = $4,
archived_at = NULL,
missing_scan_count = 0,
updated_at = NOW()
WHERE id = $1;
-- name: ClearMediaItemArchive :exec
-- File is back on disk (by path or content hash): restore visibility and
-- reset the missing-scan counter. No-op for items that were never archived.
UPDATE media_items SET archived_at = NULL, missing_scan_count = 0, updated_at = NOW()
WHERE id = $1 AND (archived_at IS NOT NULL OR missing_scan_count > 0);
-- name: PurgeExpiredArchivedMediaItems :many
-- Retention sweep at library-scan time: hard-delete archived items older
-- than the cutoff. Cascades remove reading history with the row.
DELETE FROM media_items
WHERE archived_at IS NOT NULL AND archived_at < $1
RETURNING id, title;
-- name: PurgeAllArchivedMediaItems :many
-- Manual bulk purge from the library admin page.
DELETE FROM media_items
WHERE archived_at IS NOT NULL
RETURNING id, title;
-- name: CountArchivedMediaItems :one
SELECT COUNT(*) FROM media_items WHERE archived_at IS NOT NULL;
-- name: ListHiddenMediaItems :many
-- Items hidden from libraries: either missing from disk (waiting on the
-- second scan that archives them) or already archived and pending purge.
-- Feeds the admin archived-items page. match_* columns identify an active
-- item with identical content (same SHA-256) so the UI can offer merging the
-- archived row's reading data into its active twin instead of waiting for a
-- file that will never return.
SELECT mi.*, l.name as library_name, lt.name as library_type_name,
twin.id AS match_id, COALESCE(twin.title, '') AS match_title,
twin_library.name AS match_library_name
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 LATERAL (
SELECT t.id, t.title, t.library_id
FROM media_items t
JOIN libraries tl ON t.library_id = tl.id
JOIN library_types tlt ON tl.library_type_id = tlt.id
WHERE t.file_sha256 = mi.file_sha256
AND t.id <> mi.id
AND t.archived_at IS NULL
AND t.missing_scan_count = 0
ORDER BY (tlt.id = lt.id) DESC,
(tl.id = l.id) DESC,
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = t.id) DESC,
t.created_at ASC
LIMIT 1
) twin ON true
LEFT JOIN libraries twin_library ON twin_library.id = twin.library_id
WHERE mi.archived_at IS NOT NULL OR mi.missing_scan_count > 0
ORDER BY mi.archived_at NULLS LAST, mi.updated_at DESC;
-- name: DeleteMediaItem :exec -- name: DeleteMediaItem :exec
DELETE FROM media_items WHERE id = $1; DELETE FROM media_items WHERE id = $1;
@@ -434,6 +544,8 @@ JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.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 library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND ( AND (
mi.title ILIKE sqlc.narg('search_pattern') OR mi.title ILIKE sqlc.narg('search_pattern') OR
@@ -506,6 +618,8 @@ JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.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 library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL AND (sqlc.narg('library_id')::uuid IS NULL
OR mi.library_id = sqlc.narg('library_id')::uuid) OR mi.library_id = sqlc.narg('library_id')::uuid)
-- Fuzzy author filter -- Fuzzy author filter
@@ -639,6 +753,8 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id') AND mi.library_id = sqlc.narg('library_id')
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3
AND mi.author IS NOT NULL AND mi.author IS NOT NULL
@@ -656,6 +772,8 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id') AND mi.library_id = sqlc.narg('library_id')
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3 AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3
AND mi.genre IS NOT NULL AND mi.genre IS NOT NULL
@@ -690,6 +808,8 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id') AND mi.library_id = sqlc.narg('library_id')
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3 AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3
AND mi.series IS NOT NULL AND mi.series IS NOT NULL
@@ -707,6 +827,8 @@ FROM media_items mi
JOIN libraries l ON mi.library_id = l.id JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id') AND mi.library_id = sqlc.narg('library_id')
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3 AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3
AND mi.language IS NOT NULL AND mi.language IS NOT NULL
@@ -1811,6 +1933,24 @@ SELECT * FROM media_items WHERE file_sha256 = $1;
-- name: GetMediaItemBySHA256AndLibrary :one -- name: GetMediaItemBySHA256AndLibrary :one
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2; SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2;
-- name: GetMediaItemsBySHA256AndSameLibraryType :many
-- Cross-library move detection: identical content registered in a different
-- library of the same type (ebooks stay with ebooks, comics with comics).
-- Candidates may be active copies (deliberate duplicates - leave alone) or
-- hidden/archived rows whose file vanished (move candidates - the scanner
-- verifies disk state before repointing).
SELECT mi.*, lt.name AS library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
JOIN libraries target_lib ON target_lib.id = sqlc.arg('library_id')
WHERE mi.file_sha256 = sqlc.arg('file_sha256')
AND mi.library_id <> sqlc.arg('library_id')
AND lt.id = target_lib.library_type_id
ORDER BY (mi.archived_at IS NOT NULL) DESC,
mi.missing_scan_count DESC,
mi.created_at ASC;
-- List all media items sharing a SHA-256 hash within a library (hash conflict group) -- List all media items sharing a SHA-256 hash within a library (hash conflict group)
-- name: ListMediaItemsBySHA256AndLibrary :many -- name: ListMediaItemsBySHA256AndLibrary :many
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path; SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path;
@@ -2056,6 +2196,8 @@ SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id
FROM collection_items ci FROM collection_items ci
JOIN media_items mi ON ci.media_item_id = mi.id JOIN media_items mi ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1 WHERE ci.collection_id = $1
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY ci.added_at DESC; ORDER BY ci.added_at DESC;
-- Get collections for book -- Get collections for book
@@ -2362,12 +2504,16 @@ INNER JOIN (
ORDER BY media_item_id, last_read_at DESC ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id ) rp ON rp.media_item_id = mi.id
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY rp.last_read_at DESC ORDER BY rp.last_read_at DESC
LIMIT sqlc.narg('limit'); LIMIT sqlc.narg('limit');
-- name: GetRecentlyAddedItems :many -- name: GetRecentlyAddedItems :many
SELECT mi.* FROM media_items mi SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
LIMIT sqlc.narg('limit'); LIMIT sqlc.narg('limit');
@@ -2381,12 +2527,16 @@ INNER JOIN (
ORDER BY media_item_id, last_read_at DESC ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id ) rp ON rp.media_item_id = mi.id
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY rp.last_read_at DESC ORDER BY rp.last_read_at DESC
LIMIT sqlc.narg('limit'); LIMIT sqlc.narg('limit');
-- name: GetNotStartedItems :many -- name: GetNotStartedItems :many
SELECT mi.* FROM media_items mi SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM reading_progress rp SELECT 1 FROM reading_progress rp
WHERE rp.media_item_id = mi.id WHERE rp.media_item_id = mi.id
@@ -2401,12 +2551,16 @@ SELECT mi.*, ci.excluded FROM media_items mi
INNER JOIN collection_items ci ON ci.media_item_id = mi.id INNER JOIN collection_items ci ON ci.media_item_id = mi.id
WHERE ci.collection_id = sqlc.narg('collection_id') WHERE ci.collection_id = sqlc.narg('collection_id')
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY ci.added_at DESC ORDER BY ci.added_at DESC
LIMIT sqlc.narg('limit'); LIMIT sqlc.narg('limit');
-- name: GetLibraryItems :many -- name: GetLibraryItems :many
SELECT mi.* FROM media_items mi SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.created_at DESC; ORDER BY mi.created_at DESC;
-- name: GetDistinctSeries :many -- name: GetDistinctSeries :many
@@ -2454,7 +2608,9 @@ next_books AS (
usp.last_read_at usp.last_read_at
FROM media_items mi FROM media_items mi
JOIN user_series_progress usp ON mi.series = usp.series JOIN user_series_progress usp ON mi.series = usp.series
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL) AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
ORDER BY mi.series, mi.series_number ASC NULLS LAST ORDER BY mi.series, mi.series_number ASC NULLS LAST
) )
+6
View File
@@ -77,6 +77,12 @@ func parseTableNames() ([]string, error) {
tables := make(map[string]bool) tables := make(map[string]bool)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
// Skip SQL comments: a doc line like "-- ... declared in CREATE
// TABLE above" would otherwise register a phantom table and fail
// startup verification.
if strings.HasPrefix(strings.TrimSpace(line), "--") {
continue
}
matches := pattern.FindStringSubmatch(line) matches := pattern.FindStringSubmatch(line)
if len(matches) > 1 { if len(matches) > 1 {
tableName := matches[1] tableName := matches[1]
+28
View File
@@ -0,0 +1,28 @@
package database
import (
"regexp"
"testing"
)
// TestParseTableNamesIgnoresComments guards against phantom tables parsed out
// of SQL comments (e.g. "-- ... declared in CREATE TABLE above" once
// registered a table named "above" and crashed startup verification).
func TestParseTableNamesIgnoresComments(t *testing.T) {
tables, err := parseTableNames()
if err != nil {
t.Fatalf("parseTableNames() error: %v", err)
}
if len(tables) == 0 {
t.Fatal("parseTableNames() returned no tables")
}
for _, name := range tables {
// Every parsed table must correspond to a real CREATE TABLE statement
// at the start of a (non-comment) line.
stmt := regexp.MustCompile(`(?m)^CREATE TABLE (?:IF NOT EXISTS )?(?:\w+\.)?` + regexp.QuoteMeta(name) + `\s`)
if !stmt.MatchString(SchemaFile) {
t.Errorf("parseTableNames() returned phantom table %q with no matching CREATE TABLE statement", name)
}
}
}
+123 -20
View File
@@ -6,11 +6,16 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"sync"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
@@ -106,7 +111,14 @@ type PendingRegistration struct {
SyncEndpoints map[string]string SyncEndpoints map[string]string
} }
var pendingRegistrations = make(map[string]*PendingRegistration) // pendingRegistrations holds in-flight (unapproved) device registrations.
// HTTP handlers touch it from multiple goroutines — every access must hold
// pendingMu (Go maps are not safe for concurrent use; a racing write is a
// runtime fatal, not an error).
var (
pendingRegistrations = make(map[string]*PendingRegistration)
pendingMu sync.Mutex
)
func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error { func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
req := DeviceRegistrationRequest{} req := DeviceRegistrationRequest{}
@@ -130,7 +142,9 @@ func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
pendingMu.Lock()
pendingRegistrations[registrationID] = registration pendingRegistrations[registrationID] = registration
pendingMu.Unlock()
authURL := fmt.Sprintf("%s/devices/approve/%s", h.cfg.BaseURL, registrationID) authURL := fmt.Sprintf("%s/devices/approve/%s", h.cfg.BaseURL, registrationID)
@@ -167,26 +181,34 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"}) return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
} }
pendingMu.Lock()
registration, exists := pendingRegistrations[req.RegistrationID] registration, exists := pendingRegistrations[req.RegistrationID]
if !exists { if !exists {
pendingMu.Unlock()
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"}) return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
} }
if time.Now().After(registration.ExpiresAt) { if time.Now().After(registration.ExpiresAt) {
delete(pendingRegistrations, req.RegistrationID) delete(pendingRegistrations, req.RegistrationID)
pendingMu.Unlock()
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"}) return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
} }
if registration.Approved { if registration.Approved {
delete(pendingRegistrations, req.RegistrationID) delete(pendingRegistrations, req.RegistrationID)
// Snapshot under the lock: the approver wrote these fields, and
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{ // the row is gone from the map — no other reader/writer remains.
resp := DeviceAuthStatusResponse{
Status: "approved", Status: "approved",
AuthToken: registration.AuthToken, AuthToken: registration.AuthToken,
DeviceID: registration.DeviceID, DeviceID: registration.DeviceID,
SyncEndpoints: registration.SyncEndpoints, SyncEndpoints: registration.SyncEndpoints,
}) }
pendingMu.Unlock()
return c.JSON(http.StatusOK, resp)
} }
pendingMu.Unlock()
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{ return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
Status: "pending", Status: "pending",
@@ -552,17 +574,21 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
registrationID := c.Param("registration_id") registrationID := c.Param("registration_id")
pendingMu.Lock()
registration, exists := pendingRegistrations[registrationID] registration, exists := pendingRegistrations[registrationID]
if !exists { if !exists {
pendingMu.Unlock()
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"}) return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
} }
if time.Now().After(registration.ExpiresAt) { if time.Now().After(registration.ExpiresAt) {
delete(pendingRegistrations, registrationID) delete(pendingRegistrations, registrationID)
pendingMu.Unlock()
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"}) return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
} }
if registration.Approved { if registration.Approved {
pendingMu.Unlock()
return c.JSON(http.StatusOK, map[string]interface{}{ return c.JSON(http.StatusOK, map[string]interface{}{
"message": "device already approved", "message": "device already approved",
"device_name": registration.DeviceName, "device_name": registration.DeviceName,
@@ -570,6 +596,7 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
"approved": true, "approved": true,
}) })
} }
pendingMu.Unlock()
authToken, err := generateDeviceToken() authToken, err := generateDeviceToken()
if err != nil { if err != nil {
@@ -577,23 +604,87 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
} }
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true} pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
autoSync := pgtype.Bool{Bool: true, Valid: true}
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{ // Re-approval: app reinstalls that preserve data (e.g. Android Studio
UserID: pgUserID, // installDebug over an existing install) re-register with the SAME
DeviceName: registration.DeviceName, // device_identifier, but the devices row from the previous install
DeviceType: registration.DeviceType, // still exists — device_identifier is UNIQUE, so a blind INSERT fails
DeviceIdentifier: registration.DeviceIdentifier, // with a unique violation (the historical "approve returns 500 after
AuthToken: authToken, // reinstall" bug). Look the device up first and rotate its token
SyncEnabled: syncEnabled, // instead; a fresh install MUST get fresh credentials, so the old
AutoSync: autoSync, // token is invalidated either way.
SyncFrequencyMinutes: syncFreq, rotateExisting := func(existing database.Devices) (database.Devices, bool, error) {
DeviceMetadata: []byte("{}"), // The identifier is globally unique; a row owned by another user
}) // means cross-account identifier reuse — refuse it.
if err != nil { if !existing.UserID.Valid || existing.UserID.Bytes != pgUserID.Bytes {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"}) return existing, false, nil
}
updated, err := h.db.UpdateDeviceAuthToken(c.Request().Context(),
database.UpdateDeviceAuthTokenParams{
ID: pgtype.UUID{Bytes: existing.ID.Bytes, Valid: true},
AuthToken: authToken,
})
return updated, true, err
}
existing, err := h.db.GetDeviceByIdentifier(c.Request().Context(),
registration.DeviceIdentifier)
var device database.Devices
switch {
case err == nil:
// Known device: rotate the token, keep the row (id unchanged —
// highlights/bookmarks/progress anchored to it stay valid).
var ok bool
device, ok, err = rotateExisting(existing)
if err != nil {
slog.Error("device re-approval failed",
"registration_id", registrationID, "error", err)
return c.JSON(http.StatusInternalServerError,
map[string]string{"error": "failed to approve device"})
}
if !ok {
return c.JSON(http.StatusConflict,
map[string]string{"error": "device identifier already registered to another user"})
}
case errors.Is(err, pgx.ErrNoRows):
device, err = h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
UserID: pgUserID,
DeviceName: registration.DeviceName,
DeviceType: registration.DeviceType,
DeviceIdentifier: registration.DeviceIdentifier,
AuthToken: authToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
if err != nil {
// Concurrent approve racing us onto the unique index: fall
// through to the rotate path for the winner's row.
var pgErr *pgconn.PgError
if asErr := errors.As(err, &pgErr); asErr && pgErr.Code == "23505" {
if winner, lerr := h.db.GetDeviceByIdentifier(
c.Request().Context(), registration.DeviceIdentifier); lerr == nil {
var ok bool
device, ok, err = rotateExisting(winner)
if err == nil && !ok {
return c.JSON(http.StatusConflict,
map[string]string{"error": "device identifier already registered to another user"})
}
}
}
}
if err != nil {
slog.Error("device creation failed",
"registration_id", registrationID, "error", err)
return c.JSON(http.StatusInternalServerError,
map[string]string{"error": "failed to create device"})
}
default:
slog.Error("device lookup failed",
"registration_id", registrationID, "error", err)
return c.JSON(http.StatusInternalServerError,
map[string]string{"error": "failed to look up device"})
} }
syncEndpoints := map[string]string{} syncEndpoints := map[string]string{}
@@ -607,11 +698,16 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL) syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
} }
// Publish the credentials under the map lock: the status poller
// snapshots these fields only after Approved flips, so the token can
// never be read half-written.
pendingMu.Lock()
registration.UserID = userUUID registration.UserID = userUUID
registration.Approved = true registration.Approved = true
registration.AuthToken = authToken registration.AuthToken = authToken
registration.DeviceID = device.ID.Bytes registration.DeviceID = device.ID.Bytes
registration.SyncEndpoints = syncEndpoints registration.SyncEndpoints = syncEndpoints
pendingMu.Unlock()
return c.JSON(http.StatusOK, map[string]interface{}{ return c.JSON(http.StatusOK, map[string]interface{}{
"message": "device approved successfully", "message": "device approved successfully",
@@ -625,12 +721,15 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
func (h *DeviceHandler) RejectDevice(c *echo.Context) error { func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
registrationID := c.Param("registration_id") registrationID := c.Param("registration_id")
pendingMu.Lock()
_, exists := pendingRegistrations[registrationID] _, exists := pendingRegistrations[registrationID]
if !exists { if !exists {
pendingMu.Unlock()
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"}) return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
} }
delete(pendingRegistrations, registrationID) delete(pendingRegistrations, registrationID)
pendingMu.Unlock()
return c.JSON(http.StatusOK, map[string]string{ return c.JSON(http.StatusOK, map[string]string{
"message": "device registration rejected", "message": "device registration rejected",
@@ -639,6 +738,7 @@ func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) { func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
registrations := []map[string]interface{}{} registrations := []map[string]interface{}{}
pendingMu.Lock()
for _, reg := range pendingRegistrations { for _, reg := range pendingRegistrations {
if reg.UserID == (uuid.UUID{}) { if reg.UserID == (uuid.UUID{}) {
registrations = append(registrations, map[string]interface{}{ registrations = append(registrations, map[string]interface{}{
@@ -652,6 +752,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
}) })
} }
} }
pendingMu.Unlock()
return registrations, nil return registrations, nil
} }
@@ -664,6 +765,7 @@ func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
} }
registrations := []map[string]interface{}{} registrations := []map[string]interface{}{}
pendingMu.Lock()
for _, reg := range pendingRegistrations { for _, reg := range pendingRegistrations {
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) { if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
registrations = append(registrations, map[string]interface{}{ registrations = append(registrations, map[string]interface{}{
@@ -677,6 +779,7 @@ func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
}) })
} }
} }
pendingMu.Unlock()
return c.JSON(http.StatusOK, map[string]interface{}{ return c.JSON(http.StatusOK, map[string]interface{}{
"registrations": registrations, "registrations": registrations,
+212 -55
View File
@@ -63,41 +63,41 @@ type CreateMediaItemRequest struct {
// UpdateMediaItemRequest represents the request for updating a media item // UpdateMediaItemRequest represents the request for updating a media item
type UpdateMediaItemRequest struct { type UpdateMediaItemRequest struct {
Title string `form:"title" json:"title" validate:"required,min=1,max=500"` Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
Author string `form:"author" json:"author"` Author string `form:"author" json:"author"`
ISBN string `form:"isbn" json:"isbn"` ISBN string `form:"isbn" json:"isbn"`
Description string `form:"description" json:"description"` Description string `form:"description" json:"description"`
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"` CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
CoverAction string `form:"cover_action" json:"cover_action"` CoverAction string `form:"cover_action" json:"cover_action"`
Series string `form:"series" json:"series"` Series string `form:"series" json:"series"`
SeriesNumber int32 `form:"series_number" json:"series_number"` SeriesNumber int32 `form:"series_number" json:"series_number"`
Tags []string `form:"tags" json:"tags"` Tags []string `form:"tags" json:"tags"`
ASIN string `form:"asin" json:"asin"` ASIN string `form:"asin" json:"asin"`
DatePublished string `form:"date_published" json:"date_published"` DatePublished string `form:"date_published" json:"date_published"`
Publisher string `form:"publisher" json:"publisher"` Publisher string `form:"publisher" json:"publisher"`
Contributors []string `form:"contributors" json:"contributors"` Contributors []string `form:"contributors" json:"contributors"`
Language string `form:"language" json:"language"` Language string `form:"language" json:"language"`
Edition string `form:"edition" json:"edition"` Edition string `form:"edition" json:"edition"`
PageCount int32 `form:"page_count" json:"page_count"` PageCount int32 `form:"page_count" json:"page_count"`
Genre string `form:"genre" json:"genre"` Genre string `form:"genre" json:"genre"`
CopyrightYear int32 `form:"copyright_year" json:"copyright_year"` CopyrightYear int32 `form:"copyright_year" json:"copyright_year"`
GoodreadsID string `form:"goodreads_id" json:"goodreads_id"` GoodreadsID string `form:"goodreads_id" json:"goodreads_id"`
OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"` OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"`
GoogleBooksID string `form:"google_books_id" json:"google_books_id"` GoogleBooksID string `form:"google_books_id" json:"google_books_id"`
MangaType string `form:"manga_type" json:"manga_type"` MangaType string `form:"manga_type" json:"manga_type"`
ReadingDirection string `form:"reading_direction" json:"reading_direction"` ReadingDirection string `form:"reading_direction" json:"reading_direction"`
SeriesCount int32 `form:"series_count" json:"series_count"` SeriesCount int32 `form:"series_count" json:"series_count"`
Volume int32 `form:"volume" json:"volume"` Volume int32 `form:"volume" json:"volume"`
Imprint string `form:"imprint" json:"imprint"` Imprint string `form:"imprint" json:"imprint"`
AgeRating string `form:"age_rating" json:"age_rating"` AgeRating string `form:"age_rating" json:"age_rating"`
WebURL string `form:"web_url" json:"web_url"` WebURL string `form:"web_url" json:"web_url"`
MetadataNotes string `form:"metadata_notes" json:"metadata_notes"` MetadataNotes string `form:"metadata_notes" json:"metadata_notes"`
CommunityRating float64 `form:"community_rating" json:"community_rating"` CommunityRating float64 `form:"community_rating" json:"community_rating"`
StoryArc string `form:"story_arc" json:"story_arc"` StoryArc string `form:"story_arc" json:"story_arc"`
IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"` IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"`
AlternateInfo string `form:"alternate_info" json:"alternate_info"` AlternateInfo string `form:"alternate_info" json:"alternate_info"`
ScanInformation string `form:"scan_information" json:"scan_information"` ScanInformation string `form:"scan_information" json:"scan_information"`
Summary string `form:"summary" json:"summary"` Summary string `form:"summary" json:"summary"`
} }
// CreateMediaNoteRequest represents the request for creating a media note // CreateMediaNoteRequest represents the request for creating a media note
@@ -572,24 +572,33 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
Summary: existingMedia.Summary, Summary: existingMedia.Summary,
} }
// Bulk edits are user customizations: keep existing overrides and mark
// each applied update as overridden so scans preserve it.
overrides := existingMedia.MetadataOverrides
if update.Updates.Title != nil { if update.Updates.Title != nil {
updateParams.Title = *update.Updates.Title updateParams.Title = *update.Updates.Title
overrides = utils.MergeOverrides(overrides, utils.OverrideTitle)
} }
if update.Updates.Author != nil { if update.Updates.Author != nil {
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true} updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor)
} }
if update.Updates.Genre != nil { if update.Updates.Genre != nil {
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true} updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
overrides = utils.MergeOverrides(overrides, utils.OverrideGenre)
} }
if update.Updates.Language != nil { if update.Updates.Language != nil {
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true} updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
overrides = utils.MergeOverrides(overrides, utils.OverrideLanguage)
} }
if len(update.Updates.Tags) > 0 { if len(update.Updates.Tags) > 0 {
normalizedTags := utils.NormalizeTags(update.Updates.Tags) normalizedTags := utils.NormalizeTags(update.Updates.Tags)
updateParams.Tags = normalizedTags updateParams.Tags = normalizedTags
tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags) tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags)
updateParams.TagsSearch = tagsSearch updateParams.TagsSearch = tagsSearch
overrides = utils.MergeOverrides(overrides, utils.OverrideTags)
} }
updateParams.MetadataOverrides = overrides
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams) _, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
if err != nil { if err != nil {
@@ -925,22 +934,22 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
} }
resp := map[string]interface{}{ resp := map[string]interface{}{
"id": progress.ID, "id": progress.ID,
"media_item_id": progress.MediaItemID, "media_item_id": progress.MediaItemID,
"user_id": progress.UserID, "user_id": progress.UserID,
"current_page": progress.CurrentPage, "current_page": progress.CurrentPage,
"total_pages": progress.TotalPages, "total_pages": progress.TotalPages,
"last_read_at": progress.LastReadAt, "last_read_at": progress.LastReadAt,
"percentage": progress.Percentage, "percentage": progress.Percentage,
"character_offset": progress.CharacterOffset, "character_offset": progress.CharacterOffset,
"epubcfi": progress.Epubcfi, "epubcfi": progress.Epubcfi,
"chapter": progress.Chapter, "chapter": progress.Chapter,
"chapter_progress": progress.ChapterProgress, "chapter_progress": progress.ChapterProgress,
"format_group": progress.FormatGroup, "format_group": progress.FormatGroup,
"total_characters": progress.TotalCharacters, "total_characters": progress.TotalCharacters,
"chapter_count": progress.ChapterCount, "chapter_count": progress.ChapterCount,
"last_sync_device": progress.LastSyncDevice, "last_sync_device": progress.LastSyncDevice,
"last_sync_source": progress.LastSyncSource, "last_sync_source": progress.LastSyncSource,
"last_sync_timestamp": progress.LastSyncTimestamp, "last_sync_timestamp": progress.LastSyncTimestamp,
} }
@@ -1240,6 +1249,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
} }
coverPath := existing.CoverImagePath.String coverPath := existing.CoverImagePath.String
coverUploaded := false
if req.CoverAction == "remove" { if req.CoverAction == "remove" {
coverPath = "" coverPath = ""
@@ -1251,6 +1261,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
} }
coverPath = savedPath coverPath = savedPath
coverUploaded = true
} }
} }
@@ -1259,7 +1270,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
alternateInfoBytes = []byte(req.AlternateInfo) alternateInfoBytes = []byte(req.AlternateInfo)
} }
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{ params := database.UpdateMediaItemParams{
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Title: req.Title, Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""}, Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
@@ -1297,7 +1308,17 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
AlternateInfo: alternateInfoBytes, AlternateInfo: alternateInfoBytes,
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""}, ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""}, Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
}) }
// Fields the user actually changed become overrides so future scans keep
// the custom values. An explicit cover upload/removal always overrides.
overrides := utils.DetectMetadataOverrides(params, existing)
if req.CoverAction == "remove" || coverUploaded {
overrides = utils.MergeOverrides(overrides, utils.OverrideCoverImagePath)
}
params.MetadataOverrides = overrides
item, err := mh.db.UpdateMediaItem(c.Request().Context(), params)
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
@@ -1309,6 +1330,128 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
return c.JSON(http.StatusOK, item) return c.JSON(http.StatusOK, item)
} }
// UnarchiveMediaItem handles POST /api/media-items/:id/unarchive (admin
// only). Manually restores a hidden/archived item to library visibility
// without waiting for its file to return. If the file is still gone, the
// next scan hides it again.
func (mh *MediaHandler) UnarchiveMediaItem(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.ClearMediaItemArchive(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// MergeArchivedMediaItem handles POST /api/media-items/:id/merge (admin
// only). Merges an archived/missing item's reading data (progress,
// annotations, highlights, notes, bookmarks, ratings, collections, kobo
// shelves) into an active item with identical content (same SHA-256), then
// deletes the archived row. Covers the copy-then-delete-later workflow: the
// active copy exists, the archived twin holds the real reading history.
// Per-user collisions keep the active copy's data, mirroring hash-conflict
// resolution.
func (mh *MediaHandler) MergeArchivedMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
sourceUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req struct {
TargetID string `json:"target_id"`
}
if err := c.Bind(&req); err != nil || req.TargetID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "target_id is required"})
}
targetUUID, err := uuid.Parse(req.TargetID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid target_id"})
}
if sourceUUID == targetUUID {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot merge an item into itself"})
}
ctx := c.Request().Context()
source, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "archived item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if !source.ArchivedAt.Valid && source.MissingScanCount == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "source item is not archived or missing"})
}
target, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: targetUUID, Valid: true})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "target item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if target.ArchivedAt.Valid || target.MissingScanCount > 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "target item is not active"})
}
if !source.FileSha256.Valid || source.FileSha256.String == "" ||
source.FileSha256.String != target.FileSha256.String {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "items do not share the same content hash"})
}
if err := mh.db.ReparentMediaItemChildren(ctx, database.ReparentMediaItemChildrenParams{
Column1: pgtype.UUID{Bytes: targetUUID, Valid: true},
Column2: pgtype.UUID{Bytes: sourceUUID, Valid: true},
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to merge reading data: " + err.Error()})
}
if err := mh.db.DeleteMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to remove archived item: " + err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{
"message": "Merged reading progress, annotations, and collections into the active item",
})
}
// PurgeArchivedMediaItems handles POST /api/media-items/purge-archived
// (admin only). Hard-deletes every archived item (files missing from disk for
// 2+ scans) together with its reading history. The archive retention window
// eventually does the same automatically; this is the manual bulk escape hatch.
func (mh *MediaHandler) PurgeArchivedMediaItems(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
purged, err := mh.db.PurgeAllArchivedMediaItems(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"purged": len(purged),
})
}
// RescanMediaItem handles POST /api/media-items/:id/rescan (admin only) // RescanMediaItem handles POST /api/media-items/:id/rescan (admin only)
func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error { func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c) user := MustGetAuthenticatedUser(c)
@@ -1333,7 +1476,11 @@ func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
scanner := services.NewMediaScanner(mh.db) scanner := services.NewMediaScanner(mh.db)
defer scanner.Close() defer scanner.Close()
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil { // reset_overrides=true discards user customizations first, returning the
// item to pure scanned defaults (the "Reset to Scanned" action).
resetOverrides := c.QueryParam("reset_overrides") == "true"
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, resetOverrides); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
@@ -2224,7 +2371,17 @@ func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file
return "", fmt.Errorf("media item has no file path") return "", fmt.Errorf("media item has no file path")
} }
coverRelPath := relativeFilePath + ".cover.jpg" // Custom covers live at a dedicated sidecar path (distinct from the
// scanner-generated {file}.cover.jpg) so scans can never overwrite a
// user-uploaded cover and the metadata_overrides set can protect it.
ext := ".jpg"
switch contentType {
case "image/png":
ext = ".png"
case "image/webp":
ext = ".webp"
}
coverRelPath := relativeFilePath + ".custom_cover" + ext
coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath) coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath)
if err != nil { if err != nil {
+4
View File
@@ -7,6 +7,10 @@ import "bookhoard/internal/database"
type MediaDetail struct { type MediaDetail struct {
database.MediaItems // Embedded - ALL book fields available database.MediaItems // Embedded - ALL book fields available
// Absolute on-disk location (library folder + relative path), resolved by
// the page handler so the UI can show where the file lives.
FileLocation string `json:"file_location"`
// User-specific data // User-specific data
Rating *database.MediaRatings `json:"rating,omitempty"` Rating *database.MediaRatings `json:"rating,omitempty"`
Collections []database.Collections `json:"collections"` Collections []database.Collections `json:"collections"`
+288 -11
View File
@@ -90,6 +90,9 @@ func appendToken(url, token string) string {
// catalogMediaType is the OPDS media type for an acquisition catalog feed. // catalogMediaType is the OPDS media type for an acquisition catalog feed.
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition" const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
// navFeedMediaType is the OPDS media type for a navigation catalog feed.
const navFeedMediaType = "application/atom+xml;profile=opds-catalog;kind=navigation"
// addCatalogPaginationLinks adds OPDS pagination links (self, start, first, // addCatalogPaginationLinks adds OPDS pagination links (self, start, first,
// previous, next, last) and OpenSearch paging metadata (totalResults, // previous, next, last) and OpenSearch paging metadata (totalResults,
// itemsPerPage, startIndex) to a feed based on the current page position. // itemsPerPage, startIndex) to a feed based on the current page position.
@@ -172,8 +175,109 @@ func formatLabelFromPath(path string) string {
} }
} }
// GetDeviceCatalog returns the OPDS catalog feed for a device // GetDeviceCatalog serves the OPDS root feed for a device.
//
// By default it returns a navigation feed: an "All Books" entry (the flat
// cross-library catalog) followed by one folder entry per visible library.
// OPDS clients render navigation entries as folders, so each library's
// books — and its library-scoped search — live under their own node instead
// of interleaving duplicate copies of the same book in one flat list.
// Passing ?all=1 serves the flat cross-library acquisition feed directly,
// preserving the pre-folders behavior.
func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error { func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
if c.QueryParam("all") == "1" {
return h.serveAllBooksCatalog(c)
}
deviceID := c.Param("deviceId")
// Get base URLs
_, opdsBaseURL, err := h.getBaseURLs(c)
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
}
// Parse device ID
deviceUUID, err := uuid.Parse(deviceID)
if err != nil {
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
}
// Verify device exists
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
if err != nil {
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
}
// Get user's visible libraries
userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
}
token := h.getAuthToken(c)
feed := opds.NewFeed(
fmt.Sprintf("urn:uuid:%s", deviceID),
"Bookhoard Library",
)
// OpenSearch discovery: the root search stays global (all libraries).
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
updated := time.Now().UTC().Format("2006-01-02T15:04:05Z")
// "All Books" first: the flat cross-library catalog (?all=1).
allBooks := opds.NewEntry(
fmt.Sprintf("urn:uuid:%s-all-books", deviceID),
"All Books",
"",
updated,
)
allBooks.AddLink(appendToken(fmt.Sprintf("%s/devices/%s/catalog?all=1", opdsBaseURL, deviceID), token), catalogMediaType, "subsection")
allBooks.SetSummary("Browse every book across all your libraries")
feed.AddEntry(allBooks)
// One folder per visible library, with live book counts.
counts := make(map[pgtype.UUID]int64)
if countRows, cerr := h.db.GetVisibleLibraryMediaCounts(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}); cerr == nil {
for _, row := range countRows {
counts[row.ID] = row.MediaCount
}
}
for _, lib := range libraries {
libUUID := uuid.UUID(lib.ID.Bytes).String()
title := lib.Name
if n, ok := counts[lib.ID]; ok {
title = fmt.Sprintf("%s (%d)", lib.Name, n)
}
entry := opds.NewEntry(
fmt.Sprintf("urn:uuid:%s-library-%s", deviceID, libUUID),
title,
"",
updated,
)
entry.AddLink(appendToken(fmt.Sprintf("%s/devices/%s/library/%s/catalog", opdsBaseURL, deviceID, libUUID), token), catalogMediaType, "subsection")
entry.SetSummary("Browse books in the " + lib.Name + " library")
feed.AddEntry(entry)
}
// Generate XML
xmlString, err := feed.GenerateXMLString()
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
}
c.Response().Header().Set("Content-Type", navFeedMediaType)
return c.String(http.StatusOK, xmlString)
}
// serveAllBooksCatalog is the original flat cross-library acquisition feed:
// every book from every visible library, paginated as one list.
func (h *OPDSHandler) serveAllBooksCatalog(c *echo.Context) error {
deviceID := c.Param("deviceId") deviceID := c.Param("deviceId")
page := c.QueryParam("page") page := c.QueryParam("page")
@@ -216,8 +320,6 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
// Get user's visible libraries // Get user's visible libraries
userID := device.UserID.Bytes userID := device.UserID.Bytes
userUUID := uuid.UUID(userID)
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
if err != nil { if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries")) return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
@@ -276,7 +378,23 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search") feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
// Add entries // Add entries
for _, item := range allItems { h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, allItems, baseURL, opdsBaseURL, token)
// Generate XML
xmlString, err := feed.GenerateXMLString()
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
}
c.Response().Header().Set("Content-Type", catalogMediaType)
return c.String(http.StatusOK, xmlString)
}
// addAcquisitionEntries fills an acquisition feed with book entries for the
// given items. Shared body of the all-books and per-library catalogs.
func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed, device database.Devices, deviceID string, items []database.ListMediaItemsByLibraryRow, baseURL, opdsBaseURL, token string) {
userUUID := uuid.UUID(device.UserID.Bytes)
for _, item := range items {
bookUUID := uuid.UUID(item.ID.Bytes).String() bookUUID := uuid.UUID(item.ID.Bytes).String()
title := item.Title title := item.Title
author := "" author := ""
@@ -323,7 +441,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
} }
// Add collections as categories // Add collections as categories
collections, err := h.db.GetCollectionsForBook(c.Request().Context(), pgtype.UUID{Bytes: item.ID.Bytes, Valid: true}) collections, err := h.db.GetCollectionsForBook(ctx, pgtype.UUID{Bytes: item.ID.Bytes, Valid: true})
if err == nil { if err == nil {
collectionScheme := fmt.Sprintf("%s/collections", baseURL) collectionScheme := fmt.Sprintf("%s/collections", baseURL)
for _, col := range collections { for _, col := range collections {
@@ -335,6 +453,125 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
feed.AddEntry(entry) feed.AddEntry(entry)
} }
}
// GetLibraryCatalog serves an acquisition feed scoped to one of the device
// owner's visible libraries. Its search link is library-scoped: OPDS clients
// only substitute {searchTerms}, so opening search from inside this folder
// searches within this library only.
func (h *OPDSHandler) GetLibraryCatalog(c *echo.Context) error {
deviceID := c.Param("deviceId")
libraryID := c.Param("libraryId")
page := c.QueryParam("page")
perPage := c.QueryParam("per_page")
includeFormat := c.QueryParam("include_format")
// Parse pagination parameters
pageNum := 1
if page != "" {
if num, err := strconv.Atoi(page); err == nil && num > 0 {
pageNum = num
}
}
perPageNum := h.opdsDefaultPageSize()
maxPerPage := h.opdsMaxPageSize()
if perPage != "" {
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage {
perPageNum = num
}
}
// Get base URLs
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
}
// Parse IDs
deviceUUID, err := uuid.Parse(deviceID)
if err != nil {
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid library ID"))
}
// Verify device exists
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
if err != nil {
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
}
// The library must be visible to the device owner.
userID := device.UserID.Bytes
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
}
var library *database.GetUserVisibleLibrariesRow
for i := range libraries {
if libraries[i].ID.Bytes == libUUID {
library = &libraries[i]
break
}
}
if library == nil {
return c.XML(http.StatusForbidden, opds.NewErrorFeed("Library not accessible"))
}
// Get media items
items, err := h.db.ListMediaItemsByLibrary(c.Request().Context(), library.ID)
if err != nil {
items = []database.ListMediaItemsByLibraryRow{}
}
// Filter by format if specified
if includeFormat != "" {
filteredItems := []database.ListMediaItemsByLibraryRow{}
for _, item := range items {
if strings.EqualFold(item.FormatGroup, includeFormat) {
filteredItems = append(filteredItems, item)
}
}
items = filteredItems
}
// Pagination
totalItems := len(items)
startIdx := (pageNum - 1) * perPageNum
endIdx := startIdx + perPageNum
if startIdx >= totalItems {
items = []database.ListMediaItemsByLibraryRow{}
} else if endIdx > totalItems {
items = items[startIdx:]
} else {
items = items[startIdx:endIdx]
}
// Create OPDS feed
feed := opds.NewFeed(
fmt.Sprintf("urn:uuid:%s-library-%s", deviceID, libUUID),
library.Name,
)
token := h.getAuthToken(c)
catalogBase := fmt.Sprintf("%s/devices/%s/library/%s/catalog", opdsBaseURL, deviceID, libUUID)
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
// Library-scoped search discovery: the template pins this library.
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}&library_id=%s", opdsBaseURL, deviceID, libUUID), token)
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
// Up-link back to the root navigation feed.
rootURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
feed.AddLink(rootURL, navFeedMediaType, "up")
// Add entries
h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, items, baseURL, opdsBaseURL, token)
// Generate XML // Generate XML
xmlString, err := feed.GenerateXMLString() xmlString, err := feed.GenerateXMLString()
@@ -342,7 +579,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed")) return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
} }
c.Response().Header().Set("Content-Type", "application/atom+xml;profile=opds-catalog;kind=acquisition") c.Response().Header().Set("Content-Type", catalogMediaType)
return c.String(http.StatusOK, xmlString) return c.String(http.StatusOK, xmlString)
} }
@@ -377,20 +614,50 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
// Get user's visible libraries // Get user's visible libraries
userID := device.UserID.Bytes userID := device.UserID.Bytes
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
if err != nil { if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries")) return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
} }
// Optional library scope: library folder feeds advertise their own search
// link with this parameter, so opening search from inside a library
// folder searches within that library only.
var scopedLibraryID pgtype.UUID
scopedLibraryName := ""
if libParam := c.QueryParam("library_id"); libParam != "" {
parsed, perr := uuid.Parse(libParam)
if perr != nil {
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid library ID"))
}
for i := range libraries {
if libraries[i].ID.Bytes == parsed {
scopedLibraryID = libraries[i].ID
scopedLibraryName = libraries[i].Name
break
}
}
if !scopedLibraryID.Valid {
return c.XML(http.StatusForbidden, opds.NewErrorFeed("Library not accessible"))
}
}
token := h.getAuthToken(c) token := h.getAuthToken(c)
// No query: serve the OpenSearch description document so clients can learn // No query: serve the OpenSearch description document so clients can learn
// the search template (contains the {searchTerms} placeholder). // the search URL template (contains the {searchTerms} placeholder).
if query == "" { if query == "" {
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token) template := fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID)
shortName := "Bookhoard"
description := "Search the Bookhoard library"
if scopedLibraryID.Valid {
template += "&library_id=" + scopedLibraryID.String()
shortName = "Bookhoard: " + scopedLibraryName
description = "Search the " + scopedLibraryName + " library"
}
searchURL := appendToken(template, token)
desc := opds.NewSearchDescription( desc := opds.NewSearchDescription(
"Bookhoard", shortName,
"Search the Bookhoard library", description,
searchURL, searchURL,
) )
xmlString, err := desc.GenerateXMLString() xmlString, err := desc.GenerateXMLString()
@@ -404,6 +671,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
// Search media items // Search media items
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{ allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true}, UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: scopedLibraryID,
SearchPattern: pgtype.Text{String: "%" + query + "%", Valid: true}, SearchPattern: pgtype.Text{String: "%" + query + "%", Valid: true},
Offset: pgtype.Int4{Int32: 0, Valid: true}, Offset: pgtype.Int4{Int32: 0, Valid: true},
Limit: pgtype.Int4{Int32: 1000, Valid: true}, Limit: pgtype.Int4{Int32: 1000, Valid: true},
@@ -451,6 +719,15 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
entry.SetSummary(item.Description.String) entry.SetSummary(item.Description.String)
} }
// Global (unscoped) search: label which library each copy lives in so
// duplicate copies of the same book are distinguishable.
if !scopedLibraryID.Valid && item.LibraryName != "" {
entry.AddCategory(baseURL+"/libraries", item.LibraryName)
if !item.Description.Valid || item.Description.String == "" {
entry.SetSummary("Library: " + item.LibraryName)
}
}
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token) downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype)) entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
+21
View File
@@ -102,6 +102,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
// Determine if panel detection should be enabled // Determine if panel detection should be enabled
enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup) enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup)
treatAsComic := ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup)
// Get reading progress // Get reading progress
var progress database.ReadingProgress var progress database.ReadingProgress
@@ -133,6 +134,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
"progress": progress, "progress": progress,
"bookmarks": bookmarks, "bookmarks": bookmarks,
"enable_panel_detection": enablePanelDetection, "enable_panel_detection": enablePanelDetection,
"treat_as_comic": treatAsComic,
"format_group": mediaItem.FormatGroup, "format_group": mediaItem.FormatGroup,
"manga_type": mediaItem.MangaType, "manga_type": mediaItem.MangaType,
"reading_direction": mediaItem.ReadingDirection, "reading_direction": mediaItem.ReadingDirection,
@@ -639,6 +641,25 @@ func (h *ReaderHandler) ParseEbook(c *echo.Context) error {
}) })
} }
// ShouldTreatAsComic reports whether a fixed-layout item that lives in a
// comics or manga library should get comic reader treatment: bookmarks
// only — no text-selection highlights, no annotations, no in-book search.
// Comic archives already behave this way; this flag extends it to fixed-
// layout PDFs shelved in comic libraries (scanned or official manga PDFs).
func ShouldTreatAsComic(libraryType string, formatGroup string) bool {
comicLibraries := map[string]bool{
"manga": true,
"comics": true,
}
fixedFormats := map[string]bool{
"fixed_layout": true,
"comic_archive": true,
}
return comicLibraries[libraryType] && fixedFormats[formatGroup]
}
// shouldEnablePanelDetection determines if panel detection should be enabled // shouldEnablePanelDetection determines if panel detection should be enabled
// based on library type and format group // based on library type and format group
func shouldEnablePanelDetection(libraryType string, formatGroup string) bool { func shouldEnablePanelDetection(libraryType string, formatGroup string) bool {
+98 -9
View File
@@ -7,13 +7,17 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"time" "time"
"bookhoard/internal/config"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"bookhoard/internal/services" "bookhoard/internal/services"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"bookhoard/internal/version"
"bookhoard/templates" "bookhoard/templates"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -293,6 +297,18 @@ func registerFrontendRoutes(cfg *Config) {
libraryID := libRes.LibraryID libraryID := libRes.LibraryID
libData := libRes.Libraries libData := libRes.Libraries
// Steam Deck behavior: an offline library (all folders missing, e.g.
// an unmounted external drive) shows an empty shelf with a notice
// instead of querying items. Nothing is marked or purged.
if !libRes.IsAll {
for _, l := range libData {
if l.ID == libraryID && l.Offline {
errorMsg = l.Name + " is currently unavailable - its storage is not connected. Your books are safe and will return when the drive is reattached."
break
}
}
}
// Fetch saved filters for SSR // Fetch saved filters for SSR
userUUID, _ := uuid.Parse(user.ID) userUUID, _ := uuid.Parse(user.ID)
var savedFilters []database.SavedFilters var savedFilters []database.SavedFilters
@@ -834,6 +850,59 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
})) }))
frontendProtected.GET("/admin/archived", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
rows, err := cfg.Queries.ListHiddenMediaItems(c.Request().Context())
if err != nil {
log.Printf("ListHiddenMediaItems failed: %v", err)
rows = []database.ListHiddenMediaItemsRow{}
}
retentionDays := config.ArchiveRetentionDays()
items := make([]templates.ArchivedItem, 0, len(rows))
for _, row := range rows {
itemUUID, _ := uuid.FromBytes(row.ID.Bytes[0:16])
item := templates.ArchivedItem{
ID: itemUUID.String(),
Title: row.Title,
Author: getText(row.Author),
LibraryName: row.LibraryName,
FilePath: row.FilePath,
MissingScans: row.MissingScanCount,
}
if row.MatchID.Valid {
item.MatchID = uuid.UUID(row.MatchID.Bytes).String()
item.MatchTitle = row.MatchTitle
item.MatchLibraryName = getText(row.MatchLibraryName)
}
if row.ArchivedAt.Valid {
archived := row.ArchivedAt.Time
item.ArchivedAt = &archived
switch {
case retentionDays <= 0:
item.Status = "Archived - kept until manually purged (retention disabled)"
default:
purge := archived.AddDate(0, 0, retentionDays)
item.PurgeAt = &purge
item.Status = "Archived - permanently deleted " + purge.Format("Jan 2, 2006")
}
} else {
item.Status = fmt.Sprintf("Missing from disk - archives on the next scan (%d/2)", row.MissingScanCount)
}
items = append(items, item)
}
var buf bytes.Buffer
if err := templates.AdminArchived(user, items, retentionDays).Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
frontendProtected.GET("/admin/library", handlers.AdminMiddleware(func(c *echo.Context) error { frontendProtected.GET("/admin/library", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg) user, err := getTemplateUserWithTheme(c, cfg)
if err != nil { if err != nil {
@@ -882,7 +951,8 @@ func registerFrontendRoutes(cfg *Config) {
} }
var buf bytes.Buffer var buf bytes.Buffer
err = templates.AdminLibrary(user, libData, userData).Render(c.Request().Context(), &buf) archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context())
err = templates.AdminLibrary(user, libData, userData, archivedCount).Render(c.Request().Context(), &buf)
if err != nil { if err != nil {
return err return err
} }
@@ -984,12 +1054,12 @@ func registerFrontendRoutes(cfg *Config) {
} }
totalData := counts.ProgressCount + counts.HighlightsCount + counts.BookmarksCount + counts.NotesCount + counts.CollectionsCount totalData := counts.ProgressCount + counts.HighlightsCount + counts.BookmarksCount + counts.NotesCount + counts.CollectionsCount
conflict.Items = append(conflict.Items, templates.HashConflictItemData{ conflict.Items = append(conflict.Items, templates.HashConflictItemData{
ID: uuid.UUID(mi.ID.Bytes).String(), ID: uuid.UUID(mi.ID.Bytes).String(),
Title: mi.Title, Title: mi.Title,
Author: mi.Author.String, Author: mi.Author.String,
FilePath: mi.FilePath, FilePath: mi.FilePath,
FileSize: mi.FileSize.Int64, FileSize: mi.FileSize.Int64,
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections", UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount), counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount),
HasReadingData: totalData > 0, HasReadingData: totalData > 0,
}) })
@@ -1090,9 +1160,9 @@ func registerFrontendRoutes(cfg *Config) {
// already have their own dedicated UI cards (timezone dropdown, scan // already have their own dedicated UI cards (timezone dropdown, scan
// settings) so they aren't listed twice. // settings) so they aren't listed twice.
dedicatedUI := map[string]bool{ dedicatedUI := map[string]bool{
"default_timezone": true, "default_timezone": true,
"scan_poll_interval_seconds": true, "scan_poll_interval_seconds": true,
"auto_scan_enabled": true, "auto_scan_enabled": true,
} }
var tunableSettings []templates.SettingEntry var tunableSettings []templates.SettingEntry
if cfg.Settings != nil { if cfg.Settings != nil {
@@ -1267,6 +1337,23 @@ func registerFrontendRoutes(cfg *Config) {
mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true} mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true}
} }
// Resolve the on-disk location (library folder + relative path) so the
// detail page can show where the file lives, even for sparse metadata.
// Admin-only: everyday users never receive the absolute path.
fileLocation := ""
if user.Role == "admin" {
fileLocation = mediaItem.FilePath
if folders, ferr := cfg.Queries.GetLibraryFolders(c.Request().Context(), mediaItem.LibraryID); ferr == nil {
for _, folder := range folders {
candidate := filepath.Join(folder.FolderPath, mediaItem.FilePath)
if _, serr := os.Stat(candidate); serr == nil {
fileLocation = candidate
break
}
}
}
}
// Fetch rating // Fetch rating
var rating *database.MediaRatings var rating *database.MediaRatings
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{ userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
@@ -1330,6 +1417,7 @@ func registerFrontendRoutes(cfg *Config) {
// Assemble response (no field duplication!) // Assemble response (no field duplication!)
detail := handlers.MediaDetail{ detail := handlers.MediaDetail{
MediaItems: mediaItem, // Embedded - ALL fields available MediaItems: mediaItem, // Embedded - ALL fields available
FileLocation: fileLocation,
Rating: rating, Rating: rating,
Collections: collections, Collections: collections,
ReadingProgress: progress, ReadingProgress: progress,
@@ -1368,6 +1456,7 @@ func (cfg *Config) GetHealth(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{ return c.JSON(http.StatusOK, map[string]interface{}{
"status": "healthy", "status": "healthy",
"version": version.Version,
"database": "connected", "database": "connected",
"scan": map[string]interface{}{ "scan": map[string]interface{}{
"scan_in_progress": cfg.Worker.HasActiveScans(), "scan_in_progress": cfg.Worker.HasActiveScans(),
+24 -5
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"log" "log"
"net/url" "net/url"
"os"
"time" "time"
"bookhoard/internal/database" "bookhoard/internal/database"
@@ -94,11 +95,11 @@ const selectedLibraryCookie = "selectedLibrary"
const allLibrariesSentinel = "__all__" const allLibrariesSentinel = "__all__"
type LibraryResolution struct { type LibraryResolution struct {
LibraryID string LibraryID string
IsAll bool IsAll bool
LibUUID pgtype.UUID LibUUID pgtype.UUID
Libraries []templates.LibraryData Libraries []templates.LibraryData
FirstID string FirstID string
} }
func getText(t pgtype.Text) string { func getText(t pgtype.Text) string {
@@ -132,12 +133,30 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
res.Libraries = make([]templates.LibraryData, len(libraries)) res.Libraries = make([]templates.LibraryData, len(libraries))
for i, lib := range libraries { for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
// Steam Deck SD-card behavior: a library whose folders are all
// missing (unmounted drive) renders offline - hidden contents, no
// marking, no purging - and returns when the storage does.
offline := false
folders, folderErr := cfg.Queries.GetLibraryFolders(c.Request().Context(), lib.ID)
if folderErr != nil || len(folders) == 0 {
offline = true
} else {
anyFolderExists := false
for _, folder := range folders {
if _, statErr := os.Stat(folder.FolderPath); statErr == nil {
anyFolderExists = true
break
}
}
offline = !anyFolderExists
}
res.Libraries[i] = templates.LibraryData{ res.Libraries[i] = templates.LibraryData{
ID: libUUID.String(), ID: libUUID.String(),
Name: lib.Name, Name: lib.Name,
Description: getText(lib.Description), Description: getText(lib.Description),
TypeName: lib.TypeName, TypeName: lib.TypeName,
MediaCount: countMap[libUUID.String()], MediaCount: countMap[libUUID.String()],
Offline: offline,
} }
} }
+3
View File
@@ -62,6 +62,9 @@ func registerMediaRoutes(cfg *Config) {
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem) admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem) admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem) 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)
admin.POST("/media-items/:id/merge", cfg.MediaHandler.MergeArchivedMediaItem)
// Shelf management (protected) // Shelf management (protected)
protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf) protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf)
+1
View File
@@ -19,6 +19,7 @@ func registerOPDSRoutes(cfg *Config) {
opds := e.Group("/opds/devices") opds := e.Group("/opds/devices")
opds.Use(cfg.DeviceAuthMiddleware.Authenticate) opds.Use(cfg.DeviceAuthMiddleware.Authenticate)
opds.GET("/:deviceId/catalog", cfg.OPDSHandler.GetDeviceCatalog) opds.GET("/:deviceId/catalog", cfg.OPDSHandler.GetDeviceCatalog)
opds.GET("/:deviceId/library/:libraryId/catalog", cfg.OPDSHandler.GetLibraryCatalog)
opds.GET("/:deviceId/search", cfg.OPDSHandler.SearchDeviceCatalog) opds.GET("/:deviceId/search", cfg.OPDSHandler.SearchDeviceCatalog)
opds.GET("/:deviceId/nav", cfg.OPDSHandler.GetDeviceNavigation) opds.GET("/:deviceId/nav", cfg.OPDSHandler.GetDeviceNavigation)
opds.GET("/:deviceId/download/:bookId", cfg.OPDSHandler.DownloadBook) opds.GET("/:deviceId/download/:bookId", cfg.OPDSHandler.DownloadBook)
+8
View File
@@ -66,6 +66,13 @@ func registerReaderRoutes(cfg *Config) {
if !visible { if !visible {
return renderErrorPage(c, "Access denied", "access_denied") return renderErrorPage(c, "Access denied", "access_denied")
} }
// Library type decides comic reader treatment: fixed-layout items
// (e.g. manga PDFs) shelved in comics/manga libraries are paged
// through like comics — bookmarks only, no highlights or search.
libraryWithType, err := cfg.Queries.GetLibraryWithType(c.Request().Context(), mediaItem.LibraryID)
if err != nil {
return renderErrorPage(c, "Failed to get library info", "library_error")
}
// Convert to template types. Reading state is deliberately NOT // Convert to template types. Reading state is deliberately NOT
// fetched or embedded: the reader pulls position, bookmarks, and // fetched or embedded: the reader pulls position, bookmarks, and
// annotations from the APIs at open time so the page can never // annotations from the APIs at open time so the page can never
@@ -89,6 +96,7 @@ func registerReaderRoutes(cfg *Config) {
FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}), FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}),
TotalCharacters: mediaItem.TotalCharacters.Int64, TotalCharacters: mediaItem.TotalCharacters.Int64,
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64), EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
} }
// Render template // Render template
var buf bytes.Buffer var buf bytes.Buffer
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,410 @@
package services
import (
"archive/zip"
"bytes"
"fmt"
"image"
"image/jpeg"
"os"
"path/filepath"
"testing"
"time"
)
// createTestJPEGBytes returns the bytes of a minimal valid JPEG.
func createTestJPEGBytes() string {
var buf bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
if err := jpeg.Encode(&buf, img, nil); err != nil {
return ""
}
return buf.String()
}
// createPragmaticStyleEPUB builds an EPUB modeled on Pragmatic Bookshelf
// output: the dc namespace declared on the <metadata> element (not on
// <package>), a scheme-less ISBN identifier, an OPF-declared cover, and a
// deliberately malformed chapter body. The malformed chapter is the
// regression trigger: the previous go-epub-based extractor failed the whole
// book when any chapter was unparseable and wrote blank metadata.
func createPragmaticStyleEPUB(epubPath string) error {
file, err := os.Create(epubPath)
if err != nil {
return err
}
defer file.Close()
zipWriter := zip.NewWriter(file)
defer zipWriter.Close()
mimetypeW, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: "mimetype",
Method: zip.Store,
})
if err != nil {
return err
}
mimetypeW.Write([]byte("application/epub+zip"))
files := map[string]string{
"META-INF/container.xml": `<?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>`,
// dc namespace declared on <metadata>; identifiers carry no scheme attr
"OEBPS/content.opf": `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="PubID">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:language>en</dc:language>
<dc:title>A Common-Sense Guide</dc:title>
<dc:creator>Jay Wengrow</dc:creator>
<dc:publisher>The Pragmatic Bookshelf, LLC</dc:publisher>
<dc:description>Content that makes you a better programmer.</dc:description>
<dc:subject>Programming</dc:subject>
<dc:identifier id="PubID">978-1-68050-722-8</dc:identifier>
<meta name="cover" content="cover-image"/>
</metadata>
<manifest>
<item id="cover-image" href="images/cover.jpg" media-type="image/jpeg"/>
<item id="ch1" href="ch1.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine><itemref idref="ch1"/></spine>
</package>`,
// Malformed on purpose: unclosed tags
"OEBPS/ch1.xhtml": `<html><body><p>unclosed paragraph`,
"OEBPS/images/cover.jpg": createTestJPEGBytes(),
}
for name, content := range files {
w, err := zipWriter.Create(name)
if err != nil {
return err
}
if _, err := w.Write([]byte(content)); err != nil {
return err
}
}
return zipWriter.Close()
}
// TestExtractEPUBMetadataBrokenChapter guards the regression where one
// unparseable chapter made the extractor return nothing at all: metadata must
// come from the OPF regardless of chapter-body damage.
func TestExtractEPUBMetadataBrokenChapter(t *testing.T) {
tmpDir := t.TempDir()
epubPath := filepath.Join(tmpDir, "book.epub")
if err := createPragmaticStyleEPUB(epubPath); err != nil {
t.Fatalf("failed to create test EPUB: %v", err)
}
s := NewMediaScanner(nil)
metadata, err := s.extractEPUBMetadata(epubPath)
if err != nil {
t.Fatalf("extractEPUBMetadata() error: %v", err)
}
if metadata.Title != "A Common-Sense Guide" {
t.Errorf("Title = %q, want %q", metadata.Title, "A Common-Sense Guide")
}
if metadata.Author != "Jay Wengrow" {
t.Errorf("Author = %q, want %q", metadata.Author, "Jay Wengrow")
}
if metadata.Publisher != "The Pragmatic Bookshelf, LLC" {
t.Errorf("Publisher = %q, want %q", metadata.Publisher, "The Pragmatic Bookshelf, LLC")
}
if metadata.Description == "" {
t.Error("Description missing")
}
if metadata.Language != "en" {
t.Errorf("Language = %q, want %q", metadata.Language, "en")
}
// Scheme-less identifier that normalizes to a valid ISBN must be picked up
if metadata.ISBN == "" {
t.Error("ISBN missing (scheme-less dc:identifier fallback failed)")
}
}
func TestParseOPFContentCalibreSeries(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Test Book</dc:title>
<dc:creator>Some Author</dc:creator>
<dc:date>2020-03-15</dc:date>
<dc:subject>Fiction</dc:subject>
<dc:subject>Classic</dc:subject>
<dc:identifier opf:scheme="ISBN">978-3-16-148410-0</dc:identifier>
<meta name="calibre:series" content="Great Series"/>
<meta name="calibre:series_index" content="2.5"/>
</metadata>
</package>`
metadata, err := parseOPFContent([]byte(opf))
if err != nil {
t.Fatalf("parseOPFContent() error: %v", err)
}
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2 {
t.Errorf("Series = %q/%d, want Great Series/2", metadata.Series, metadata.SeriesNumber)
}
if metadata.ISBN == "" {
t.Error("schemed ISBN not extracted")
}
wantDate := time.Date(2020, 3, 15, 0, 0, 0, 0, time.UTC)
if !metadata.PublishDate.Equal(wantDate) {
t.Errorf("PublishDate = %v, want %v", metadata.PublishDate, wantDate)
}
if len(metadata.Tags) != 2 {
t.Errorf("Tags = %v, want 2 subjects", metadata.Tags)
}
}
func TestExtractAudiobookshelfSidecar(t *testing.T) {
tests := []struct {
name string
json string
validate func(t *testing.T, m *MediaMetadata)
}{
{
name: "full sidecar",
json: `{
"title": "An Book",
"authors": ["Author One", "Author Two"],
"series": [{"series": "The Series", "sequence": "4.5"}],
"genres": ["Fantasy"],
"tags": ["tag1"],
"publishedYear": 2019,
"publisher": "ACME Books",
"description": "A very good book.",
"isbn": "978-3-16-148410-0",
"asin": "B08XYZ",
"language": "en"
}`,
validate: func(t *testing.T, m *MediaMetadata) {
if m.Title != "An Book" || m.Author != "Author One" {
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
}
if m.Series != "The Series" || m.SeriesNumber != 4 {
t.Errorf("Series = %q/%d, want The Series/4", m.Series, m.SeriesNumber)
}
if len(m.Tags) != 2 {
t.Errorf("Tags = %v, want genres+tags merged", m.Tags)
}
if m.PublishDate.Year() != 2019 {
t.Errorf("PublishDate year = %d, want 2019", m.PublishDate.Year())
}
if m.Publisher != "ACME Books" || m.Description != "A very good book." {
t.Errorf("Publisher/Description = %q/%q", m.Publisher, m.Description)
}
if m.ISBN == "" || m.ASIN != "B08XYZ" || m.Language != "en" {
t.Errorf("ISBN/ASIN/Language = %q/%q/%q", m.ISBN, m.ASIN, m.Language)
}
},
},
{
name: "sparse sidecar (real-world Audiobookshelf export)",
json: `{"title": "Sparse (1234)", "authors": ["X"], "tags": [], "description": null}`,
validate: func(t *testing.T, m *MediaMetadata) {
if m.Title != "Sparse (1234)" || m.Author != "X" {
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
}
if m.Description != "" || m.Tags != nil {
t.Error("null/empty sidecar fields must stay unset")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(tt.json), 0644); err != nil {
t.Fatal(err)
}
m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub"))
if m == nil {
t.Fatal("extractAudiobookshelfSidecar() = nil, want metadata")
}
tt.validate(t, m)
})
}
t.Run("no sidecar returns nil", func(t *testing.T) {
dir := t.TempDir()
if m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub")); m != nil {
t.Errorf("extractAudiobookshelfSidecar() = %v, want nil", m)
}
})
}
// createTestPDFWithImage builds a minimal one-page PDF embedding a JPEG
// XObject, so pdfcpu-based cover extraction has an image to find.
func createTestPDFWithImage() []byte {
jpegData := []byte(createTestJPEGBytes())
content := "q 100 0 0 100 0 0 cm /Im0 Do Q"
var buf bytes.Buffer
offsets := []int{0} // object numbers are 1-based
buf.WriteString("%PDF-1.4\n")
writeObj := func(n int, body func(w *bytes.Buffer)) {
offsets = append(offsets, buf.Len())
fmt.Fprintf(&buf, "%d 0 obj\n", n)
body(&buf)
buf.WriteString("endobj\n")
}
writeObj(1, func(w *bytes.Buffer) { w.WriteString("<< /Type /Catalog /Pages 2 0 R >>\n") })
writeObj(2, func(w *bytes.Buffer) { w.WriteString("<< /Type /Pages /Kids [3 0 R] /Count 1 >>\n") })
writeObj(3, func(w *bytes.Buffer) {
w.WriteString("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\n")
})
writeObj(4, func(w *bytes.Buffer) {
fmt.Fprintf(w, "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n", len(jpegData))
w.Write(jpegData)
w.WriteString("\nendstream\n")
})
writeObj(5, func(w *bytes.Buffer) {
fmt.Fprintf(w, "<< /Length %d >>\nstream\n%s\nendstream\n", len(content), content)
})
writeObj(6, func(w *bytes.Buffer) {
// Info dictionary deliberately richer than the sidecars in gap-fill
// tests: gap-fill must use these, never overwrite with them.
w.WriteString("<< /Title (Embedded Title) /Author (Embedded Author) /Subject (Embedded Subject) /Producer (Embedded Producer) /Keywords (embedded-kw) >>\n")
})
xrefStart := buf.Len()
fmt.Fprintf(&buf, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
for _, off := range offsets[1:] {
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
}
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R /Info 6 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xrefStart)
return buf.Bytes()
}
// TestSidecarCoverFallback guards the regression where a metadata.json
// sidecar (no cover.jpg next to the book) made extractMetadata return an
// empty CoverPath for PDFs - mergeMetadata has no PDF/EPUB cover logic, so a
// full rescan wiped cover_image_path for sidecar-managed books.
func TestSidecarCoverFallback(t *testing.T) {
dir := t.TempDir()
pdfPath := filepath.Join(dir, "book.pdf")
if err := os.WriteFile(pdfPath, createTestPDFWithImage(), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(`{"title":"Sidecar Book","authors":["A"]}`), 0644); err != nil {
t.Fatal(err)
}
s := NewMediaScanner(nil)
s.folders = []string{dir} // SetFolders requires a live DB; tests only need path relativization
metadata, err := s.extractMetadata(pdfPath)
if err != nil {
t.Fatalf("extractMetadata() error: %v", err)
}
if metadata.Title != "Sidecar Book" {
t.Errorf("Title = %q, want sidecar title", metadata.Title)
}
if metadata.CoverPath == "" {
t.Fatal("CoverPath empty - sidecar branch skipped embedded cover extraction")
}
if _, err := os.Stat(filepath.Join(dir, metadata.CoverPath)); err != nil {
t.Errorf("extracted cover not on disk: %v", err)
}
}
// TestMergeMetadataEPUBGapFill verifies that a sparse sidecar (metadata.opf
// with only a title) gets its blanks filled from the book's own embedded OPF
// while sidecar-set fields are never overwritten.
func TestMergeMetadataEPUBGapFill(t *testing.T) {
dir := t.TempDir()
epubPath := filepath.Join(dir, "gappy.epub")
f, err := os.Create(epubPath)
if err != nil {
t.Fatal(err)
}
zipWriter := zip.NewWriter(f)
mimetype, _ := zipWriter.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
mimetype.Write([]byte("application/epub+zip"))
epubFiles := map[string]string{
"META-INF/container.xml": `<?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>`,
"OEBPS/content.opf": `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="u">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Embedded Title</dc:title>
<dc:creator>Embedded Author</dc:creator>
<dc:description>Embedded description from the book.</dc:description>
<dc:publisher>Embedded Publisher</dc:publisher>
<dc:language>en</dc:language>
<dc:date>2021-06-01</dc:date>
</metadata>
<manifest/>
<spine/>
</package>`,
}
for name, content := range epubFiles {
w, err := zipWriter.Create(name)
if err != nil {
t.Fatal(err)
}
w.Write([]byte(content))
}
if err := zipWriter.Close(); err != nil {
t.Fatal(err)
}
f.Close()
sparse := &MediaMetadata{Title: "Sidecar Title", Author: "Sidecar Author"}
s := NewMediaScanner(nil)
merged, err := s.mergeMetadata(epubPath, sparse)
if err != nil {
t.Fatalf("mergeMetadata() error: %v", err)
}
if merged.Title != "Sidecar Title" || merged.Author != "Sidecar Author" {
t.Errorf("sidecar values overwritten: title=%q author=%q", merged.Title, merged.Author)
}
if merged.Description != "Embedded description from the book." {
t.Errorf("Description = %q, want embedded fill", merged.Description)
}
if merged.Publisher != "Embedded Publisher" {
t.Errorf("Publisher = %q, want embedded fill", merged.Publisher)
}
if merged.Language != "en" {
t.Errorf("Language = %q, want embedded fill", merged.Language)
}
}
// TestMergeMetadataPDFGapFill verifies the same for PDFs: a sparse sidecar
// keeps its title while author/description/publisher/tags/pagecount fill in
// from the embedded Info dictionary.
func TestMergeMetadataPDFGapFill(t *testing.T) {
dir := t.TempDir()
pdfPath := filepath.Join(dir, "gappy.pdf")
if err := os.WriteFile(pdfPath, createTestPDFWithImage(), 0644); err != nil {
t.Fatal(err)
}
sparse := &MediaMetadata{Title: "Kept Title"}
s := NewMediaScanner(nil)
merged, err := s.mergeMetadata(pdfPath, sparse)
if err != nil {
t.Fatalf("mergeMetadata() error: %v", err)
}
checks := []struct {
name string
got string
want string
}{
{"Title (sidecar wins)", merged.Title, "Kept Title"},
{"Author", merged.Author, "Embedded Author"},
{"Description", merged.Description, "Embedded Subject"},
{"Publisher", merged.Publisher, "Embedded Producer"},
}
for _, c := range checks {
if c.got != c.want {
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
}
}
if len(merged.Tags) == 0 {
t.Error("Tags empty, want keywords from Info dict")
}
if merged.PageCount != 1 {
t.Errorf("PageCount = %d, want 1 from Info dict", merged.PageCount)
}
}
@@ -0,0 +1,67 @@
package services
import (
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
func mustUUID(t *testing.T, raw string) pgtype.UUID {
t.Helper()
parsed, err := uuid.Parse(raw)
if err != nil {
t.Fatalf("invalid test uuid %q: %v", raw, err)
}
return pgtype.UUID{Bytes: parsed, Valid: true}
}
// alwaysExists simulates every candidate's file still being on disk:
// deliberate copies across libraries must never be repointed.
func TestSelectMoveCandidateAllFilesExist(t *testing.T) {
candidates := []moveCandidate{
{ID: mustUUID(t, "00000000-0000-0000-0000-000000000001"), FilePath: "a/book.epub"},
{ID: mustUUID(t, "00000000-0000-0000-0000-000000000002"), FilePath: "b/book.epub"},
}
if _, ok := selectMoveCandidate(candidates, func(moveCandidate) bool { return true }); ok {
t.Fatal("expected no move candidate when every file still exists")
}
}
// Input arrives pre-ordered (archived first, then most missing scans, then
// oldest), so the first missing candidate must win.
func TestSelectMoveCandidatePicksFirstMissing(t *testing.T) {
onDisk := moveCandidate{ID: mustUUID(t, "00000000-0000-0000-0000-000000000001"), FilePath: "a/book.epub"}
gone := moveCandidate{ID: mustUUID(t, "00000000-0000-0000-0000-000000000002"), FilePath: "b/book.epub"}
candidates := []moveCandidate{onDisk, gone}
got, ok := selectMoveCandidate(candidates, func(c moveCandidate) bool {
return c.ID == onDisk.ID
})
if !ok {
t.Fatal("expected a move candidate")
}
if got.ID != gone.ID {
t.Fatalf("expected candidate %v, got %v", gone.ID, got.ID)
}
}
func TestSelectMoveCandidateOnlyMissingQualifies(t *testing.T) {
gone := moveCandidate{ID: mustUUID(t, "00000000-0000-0000-0000-000000000001"), FilePath: "old/book.epub"}
candidates := []moveCandidate{gone}
got, ok := selectMoveCandidate(candidates, func(moveCandidate) bool { return false })
if !ok {
t.Fatal("expected the missing candidate to qualify")
}
if got.ID != gone.ID {
t.Fatalf("expected candidate %v, got %v", gone.ID, got.ID)
}
}
func TestSelectMoveCandidateEmptyList(t *testing.T) {
if _, ok := selectMoveCandidate(nil, func(moveCandidate) bool { return false }); ok {
t.Fatal("expected no candidate for an empty list")
}
}
+344
View File
@@ -0,0 +1,344 @@
package services
import (
"bytes"
"encoding/xml"
"strconv"
"strings"
)
// Calibre-modeled OPF parsing. The scanner previously scraped OPF content
// with attribute-order-sensitive regexes; real books serialize attributes in
// any order (e.g. Pragmatic/Pattinson EPUBs put id before properties and
// content before name), which silently defeated cover detection. Everything
// here is parsed with encoding/xml so attribute order and namespace prefix
// choices are irrelevant.
type opfDCValue struct {
ID string `xml:"id,attr"`
Value string `xml:",chardata"`
}
type opfIdentifier struct {
Scheme string `xml:"http://www.idpf.org/2007/opf scheme,attr"`
Value string `xml:",chardata"`
}
type opfMeta struct {
ID string `xml:"id,attr"`
Name string `xml:"name,attr"`
Content string `xml:"content,attr"`
Property string `xml:"property,attr"`
Refines string `xml:"refines,attr"`
Value string `xml:",chardata"`
}
type opfItem struct {
ID string `xml:"id,attr"`
Href string `xml:"href,attr"`
MediaType string `xml:"media-type,attr"`
Properties string `xml:"properties,attr"`
}
// opfDocument is a structured view of an OPF package document.
type opfDocument struct {
Metadata struct {
Titles []opfDCValue `xml:"http://purl.org/dc/elements/1.1/ title"`
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
Subjects []string `xml:"http://purl.org/dc/elements/1.1/ subject"`
Descriptions []string `xml:"http://purl.org/dc/elements/1.1/ description"`
Publishers []string `xml:"http://purl.org/dc/elements/1.1/ publisher"`
Dates []string `xml:"http://purl.org/dc/elements/1.1/ date"`
Languages []string `xml:"http://purl.org/dc/elements/1.1/ language"`
Identifiers []opfIdentifier `xml:"http://purl.org/dc/elements/1.1/ identifier"`
Contributors []string `xml:"http://purl.org/dc/elements/1.1/ contributor"`
Metas []opfMeta `xml:"meta"`
} `xml:"metadata"`
Manifest struct {
Items []opfItem `xml:"item"`
} `xml:"manifest"`
Spine struct {
PageProgressionDirection string `xml:"page-progression-direction,attr"`
Itemrefs []struct {
IDRef string `xml:"idref,attr"`
} `xml:"itemref"`
} `xml:"spine"`
Guide struct {
References []struct {
Type string `xml:"type,attr"`
Href string `xml:"href,attr"`
} `xml:"reference"`
} `xml:"guide"`
}
func parseOPFXML(content []byte) (*opfDocument, error) {
var doc opfDocument
if err := xml.Unmarshal(content, &doc); err != nil {
return nil, err
}
return &doc, nil
}
// refinesFor maps an element id to its EPUB3 refining metas
// (those whose refines attribute starts with '#').
func (d *opfDocument) refinesFor(id string) []opfMeta {
var out []opfMeta
if id == "" {
return out
}
for _, m := range d.Metadata.Metas {
if strings.HasPrefix(m.Refines, "#") && m.Refines[1:] == id {
out = append(out, m)
}
}
return out
}
// refinesProperty returns the value of the first refining meta carrying the
// given property (e.g. "title-type", "collection-type", "group-position").
func refinesProperty(metas []opfMeta, property string) (string, bool) {
for _, m := range metas {
if strings.EqualFold(m.Property, property) {
if v := strings.TrimSpace(m.Value); v != "" {
return v, true
}
}
}
return "", false
}
// selectTitle ports Calibre's read_title: prefer the dc:title refined as
// title-type "main"; fall back to the first non-empty title. A distinct
// subtitle (title-type containing "subtitle"/"sub-title") is joined onto the
// main title with ": ", exactly as Calibre stores it.
func (d *opfDocument) selectTitle() string {
var first, main, subtitle string
for _, t := range d.Metadata.Titles {
v := strings.TrimSpace(t.Value)
if v == "" {
continue
}
if first == "" {
first = v
}
tt, ok := refinesProperty(d.refinesFor(t.ID), "title-type")
if !ok {
continue
}
switch strings.ToLower(tt) {
case "main":
if main == "" {
main = v
}
default:
l := strings.ToLower(tt)
if strings.Contains(l, "subtitle") || strings.Contains(l, "sub-title") {
if subtitle == "" {
subtitle = v
}
}
}
}
title := main
if title == "" {
title = first
}
if subtitle != "" && subtitle != title {
title = title + ": " + subtitle
}
return title
}
// readSeries ports Calibre's read_series: EPUB3 belongs-to-collection (with a
// collection-type=series refine and group-position index) first, then the
// classic calibre:series / calibre:series_index metas.
func (d *opfDocument) readSeries() (series string, index float64) {
for _, m := range d.Metadata.Metas {
if !strings.EqualFold(m.Property, "belongs-to-collection") {
continue
}
name := strings.TrimSpace(m.Value)
if name == "" {
continue
}
refines := d.refinesFor(m.ID)
if ct, ok := refinesProperty(refines, "collection-type"); !ok || !strings.EqualFold(ct, "series") {
continue
}
if gp, ok := refinesProperty(refines, "group-position"); ok {
if v, err := strconv.ParseFloat(strings.TrimSpace(gp), 64); err == nil {
index = v
}
}
return name, index
}
for _, m := range d.Metadata.Metas {
switch m.Name {
case "calibre:series":
series = m.Content
case "calibre:series_index":
if v, err := strconv.ParseFloat(strings.TrimSpace(m.Content), 64); err == nil {
index = v
}
}
}
return series, index
}
// pageProgressionDirection returns the OPF spine's reading direction as
// "rtl" or "ltr", or "" when the file declares none (callers treat that as
// unknown, not as left-to-right). EPUB2/3 declare this on <spine>; it is
// what foliate reads client-side, and the DB column feeds clients (and the
// web reader's fixed-layout override) that need it up front.
func (d *opfDocument) pageProgressionDirection() string {
switch strings.ToLower(strings.TrimSpace(d.Spine.PageProgressionDirection)) {
case "rtl", "right-to-left":
return "rtl"
case "ltr", "left-to-right", "default":
return "ltr"
default:
return ""
}
}
// itemByID returns manifest items with id, href and media-type, keyed by id.
func (d *opfDocument) itemByID() map[string]opfItem {
m := make(map[string]opfItem, len(d.Manifest.Items))
for _, it := range d.Manifest.Items {
if it.ID != "" && it.Href != "" && it.MediaType != "" {
m[it.ID] = it
}
}
return m
}
// firstSpineItem returns the manifest item for the first spine idref.
func (d *opfDocument) firstSpineItem() (opfItem, bool) {
if len(d.Spine.Itemrefs) == 0 {
return opfItem{}, false
}
item, ok := d.itemByID()[d.Spine.Itemrefs[0].IDRef]
return item, ok
}
// isRasterMedia reports whether a manifest media-type is an image but not an
// (X)HTML document - Calibre's guard against cover *pages* masquerading as
// cover images.
func isRasterMedia(mediaType string) bool {
mt := strings.ToLower(strings.TrimSpace(mediaType))
if mt == "" {
return false
}
if strings.Contains(mt, "xml") || strings.Contains(mt, "html") {
return false
}
return strings.HasPrefix(mt, "image/")
}
// findRasterCoverInOPF ports Calibre's read_raster_cover resolution order:
// 1. manifest item with properties containing "cover-image"
// 2. <meta name="cover" content="ID"> resolved through the manifest
// 3. the first spine item being a raster image itself (store manga)
//
// Returns the OPF-relative href of the cover image, or "".
func (d *opfDocument) findRasterCoverInOPF() string {
// 1. properties="cover-image" (space-separated property list)
for _, it := range d.Manifest.Items {
for _, prop := range strings.Fields(it.Properties) {
if strings.EqualFold(prop, "cover-image") && isRasterMedia(it.MediaType) {
return it.Href
}
}
}
// 2. meta name="cover" content=<manifest image id>
byID := d.itemByID()
for _, m := range d.Metadata.Metas {
if !strings.EqualFold(m.Name, "cover") {
continue
}
if it, ok := byID[strings.TrimSpace(m.Content)]; ok && isRasterMedia(it.MediaType) {
return it.Href
}
}
// 3. first spine item is itself an image (jpeg/webp/png per Calibre)
if it, ok := d.firstSpineItem(); ok {
mt := strings.ToLower(it.MediaType)
if mt == "image/jpeg" || mt == "image/webp" || mt == "image/png" {
return it.Href
}
}
return ""
}
// coverPageHref returns the OPF-relative href of the cover *page* document to
// mine for an embedded image: the guide's type="cover" reference when
// present, otherwise the first spine item (Calibre renders the latter).
func (d *opfDocument) coverPageHref() string {
for _, ref := range d.Guide.References {
if strings.EqualFold(ref.Type, "cover") && ref.Href != "" {
return ref.Href
}
}
if it, ok := d.firstSpineItem(); ok {
if it.Href != "" && !isRasterMedia(it.MediaType) {
return it.Href
}
}
return ""
}
// isISBNLike reports whether a bare identifier value is shaped like an ISBN
// (digits, optional hyphens/spaces, optional trailing X; 10 or 13
// significant characters). Guards the scheme-less dc:identifier fallback
// against URLs and UUIDs sharing the same slot.
func isISBNLike(v string) bool {
digits := 0
for i, r := range v {
switch {
case r >= '0' && r <= '9':
digits++
case r == '-' || r == ' ':
// separator
case (r == 'X' || r == 'x') && i == len(v)-1:
digits++ // ISBN-10 check character
default:
return false
}
}
return digits == 10 || digits == 13
}
// findImageReferenceInPage extracts the first raster image reference from a
// cover (X)HTML page: <img src="..."> or SVG <image xlink:href="...">.
// Token-based parsing keeps it tolerant of mixed namespaces and fragments.
// Returns the reference relative to the page document, or "".
func findImageReferenceInPage(pageContent []byte) string {
decoder := xml.NewDecoder(bytes.NewReader(pageContent))
for {
tok, err := decoder.Token()
if err != nil {
return ""
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
switch strings.ToLower(start.Name.Local) {
case "img":
for _, a := range start.Attr {
if strings.EqualFold(a.Name.Local, "src") && strings.TrimSpace(a.Value) != "" {
return strings.TrimSpace(a.Value)
}
}
case "image":
for _, a := range start.Attr {
if strings.EqualFold(a.Name.Local, "href") && strings.TrimSpace(a.Value) != "" {
return strings.TrimSpace(a.Value)
}
}
}
}
}
+297
View File
@@ -0,0 +1,297 @@
package services
import (
"archive/zip"
"os"
"path/filepath"
"testing"
)
// helper to build an EPUB zip from a file map for cover tests
func writeEPUB(t *testing.T, path string, files map[string]string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
w := zip.NewWriter(f)
mimetype, err := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
if err != nil {
t.Fatal(err)
}
mimetype.Write([]byte("application/epub+zip"))
for name, content := range files {
fw, err := w.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
}
const containerXML = `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`
const tinyJPEG = "\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
// TestFindCoverInOPFAttributeOrder guards the regression where attribute
// order defeated regex scraping: this OPF mirrors Grand Central's "3 Days to
// Live" serialization (href before id, content before name on the meta tag).
func TestFindCoverInOPFAttributeOrder(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>3 Days to Live</dc:title>
<meta content="cover-image" name="cover"/>
</metadata>
<manifest>
<item href="images/9781538752760.jpg" id="cover-image" media-type="image/jpeg" properties="cover-image"/>
</manifest>
<spine/>
</package>`
files := map[string]string{
"META-INF/container.xml": containerXML,
"OEBPS/package.opf": opf,
"OEBPS/images/9781538752760.jpg": tinyJPEG,
}
epubPath := filepath.Join(t.TempDir(), "book.epub")
writeEPUB(t, epubPath, files)
s := NewMediaScanner(nil)
coverPath, err := s.extractEPUBCover(epubPath)
if err != nil {
t.Fatalf("extractEPUBCover() error: %v", err)
}
if coverPath == "" {
t.Fatal("cover not extracted - attribute order still defeats resolution")
}
if _, err := os.Stat(coverPath); err != nil {
t.Fatalf("cover file not written: %v", err)
}
}
// TestFindCoverInOPFCoverPage covers books that declare no raster cover at
// all: the classic EPUB2/Adobe structure where cover.xhtml wraps the image
// (here via SVG), reachable through the guide reference or first spine item.
func TestFindCoverInOPFCoverPage(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Old Adobe Book</dc:title>
</metadata>
<manifest>
<item id="coverpage" href="text/cover.xhtml" media-type="application/xhtml+xml"/>
<item id="coverimg" href="art/cover-wrap.jpg" media-type="image/jpeg"/>
</manifest>
<spine><itemref idref="coverpage"/></spine>
<guide><reference type="cover" href="text/cover.xhtml"/></guide>
</package>`
coverPage := `<?xml version="1.0"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
<div><svg xmlns="http://www.w3.org/2000/svg"><image xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="../art/cover-wrap.jpg"/></svg></div>
</body>
</html>`
files := map[string]string{
"META-INF/container.xml": containerXML,
"OEBPS/package.opf": opf,
"OEBPS/text/cover.xhtml": coverPage,
"OEBPS/art/cover-wrap.jpg": tinyJPEG,
}
epubPath := filepath.Join(t.TempDir(), "adobe.epub")
writeEPUB(t, epubPath, files)
s := NewMediaScanner(nil)
coverPath, err := s.extractEPUBCover(epubPath)
if err != nil {
t.Fatalf("extractEPUBCover() error: %v", err)
}
if coverPath == "" {
t.Fatal("cover-page fallback failed to find SVG-wrapped image")
}
}
// TestFindCoverInOPFImageFirstSpine covers store manga whose first spine
// item is a raster image itself (Calibre's third resolution step).
func TestFindCoverInOPFImageFirstSpine(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Manga Vol 1</dc:title></metadata>
<manifest>
<item id="p1" href="pages/0001.jpg" media-type="image/jpeg"/>
</manifest>
<spine><itemref idref="p1"/></spine>
</package>`
files := map[string]string{
"META-INF/container.xml": containerXML,
"OEBPS/package.opf": opf,
"OEBPS/pages/0001.jpg": tinyJPEG,
}
epubPath := filepath.Join(t.TempDir(), "manga.epub")
writeEPUB(t, epubPath, files)
s := NewMediaScanner(nil)
coverPath, err := s.extractEPUBCover(epubPath)
if err != nil {
t.Fatalf("extractEPUBCover() error: %v", err)
}
if coverPath == "" {
t.Fatal("image-first spine cover not detected")
}
}
// TestParseOPFContentTitleTypeAndSeries covers EPUB3 refines-based title
// selection (main + subtitle joined Calibre-style) and belongs-to-collection
// series with collection-type and group-position refines.
func TestParseOPFContentTitleTypeAndSeries(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title id="t1">The Main Title</dc:title>
<dc:title id="t2">The Subtitle</dc:title>
<meta refines="#t1" property="title-type">main</meta>
<meta refines="#t2" property="title-type">subtitle</meta>
<dc:subject>Programming</dc:subject>
<dc:subject>Algorithms</dc:subject>
<dc:identifier>urn:isbn:978-3-16-148410-0</dc:identifier>
<meta id="coll1" property="belongs-to-collection">Great Series</meta>
<meta refines="#coll1" property="collection-type">series</meta>
<meta refines="#coll1" property="group-position">4.5</meta>
</metadata>
</package>`
metadata, err := parseOPFContent([]byte(opf))
if err != nil {
t.Fatalf("parseOPFContent() error: %v", err)
}
if want := "The Main Title: The Subtitle"; metadata.Title != want {
t.Errorf("Title = %q, want %q", metadata.Title, want)
}
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4 {
t.Errorf("Series = %q/%d, want Great Series/4", metadata.Series, metadata.SeriesNumber)
}
if metadata.ISBN == "" {
t.Error("urn:isbn: identifier not extracted")
}
if metadata.Genre != "Programming" {
t.Errorf("Genre = %q, want first subject %q", metadata.Genre, "Programming")
}
if len(metadata.Tags) != 2 {
t.Errorf("Tags = %v, want both subjects", metadata.Tags)
}
}
// TestParseOPFContentSubjectsWithCommas verifies subject headings keep their
// embedded commas as single tags (Library of Congress style headings).
func TestParseOPFContentSubjectsWithCommas(t *testing.T) {
opf := `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>A Study in Scarlet</dc:title>
<dc:subject>Holmes, Sherlock (Fictitious character) -- Fiction</dc:subject>
</metadata>
</package>`
metadata, err := parseOPFContent([]byte(opf))
if err != nil {
t.Fatalf("parseOPFContent() error: %v", err)
}
if len(metadata.Tags) != 1 {
t.Errorf("Tags = %v, want exactly 1 unsplit subject heading", metadata.Tags)
}
}
// TestResolveOPFPath checks URL decoding and posix normalization of
// OPF-relative hrefs.
func TestResolveOPFPath(t *testing.T) {
tests := []struct {
opfPath, href, want string
}{
{"OEBPS/package.opf", "images/cover.jpg", "OEBPS/images/cover.jpg"},
{"package.opf", "cover.jpg", "cover.jpg"},
{"OEBPS/package.opf", "../cover.jpg", "cover.jpg"},
{"OEBPS/package.opf", "my%20covers/a%20cover.jpg", "OEBPS/my covers/a cover.jpg"},
}
for _, tt := range tests {
if got := resolveOPFPath(tt.opfPath, tt.href); got != tt.want {
t.Errorf("resolveOPFPath(%q, %q) = %q, want %q", tt.opfPath, tt.href, got, tt.want)
}
}
}
// TestParseOPFContentPageProgressionDirection verifies the EPUB spine's
// page-progression-direction feeds ReadingDirection, and that an undeclared
// direction stays empty rather than forcing left-to-right.
func TestParseOPFContentPageProgressionDirection(t *testing.T) {
makeOPF := func(spineAttrs string) string {
return `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Dir Test</dc:title></metadata>
<manifest><item id="p1" href="p1.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine` + spineAttrs + `><itemref idref="p1"/></spine>
</package>`
}
tests := []struct {
name string
spineAttrs string
want string
}{
{"rtl declared lowercase", ` page-progression-direction="rtl"`, "rtl"},
{"rtl declared uppercase", ` page-progression-direction="RTL"`, "rtl"},
{"ltr declared", ` page-progression-direction="ltr"`, "ltr"},
{"undeclared stays empty", ``, ""},
{"unknown value stays empty", ` page-progression-direction="sideways"`, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
metadata, err := parseOPFContent([]byte(makeOPF(tt.spineAttrs)))
if err != nil {
t.Fatalf("parseOPFContent() error: %v", err)
}
if metadata.ReadingDirection != tt.want {
t.Errorf("ReadingDirection = %q, want %q", metadata.ReadingDirection, tt.want)
}
})
}
}
// TestMergeMetadataReadingDirectionGapFill verifies the sidecar wins when it
// declares a direction, while an embedded-only direction fills the blank.
func TestMergeMetadataReadingDirectionGapFill(t *testing.T) {
dir := t.TempDir()
epubPath := filepath.Join(dir, "dir.epub")
files := map[string]string{
"META-INF/container.xml": containerXML,
"OEBPS/package.opf": `<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>RTL Book</dc:title></metadata>
<manifest><item id="p1" href="p1.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine page-progression-direction="rtl"><itemref idref="p1"/></spine>
</package>`,
"OEBPS/p1.xhtml": `<html><body><p>hi</p></body></html>`,
}
writeEPUB(t, epubPath, files)
s := NewMediaScanner(nil)
// Sidecar blank -> embedded rtl fills it
merged, err := s.mergeMetadata(epubPath, &MediaMetadata{Title: "Sidecar"})
if err != nil {
t.Fatalf("mergeMetadata() error: %v", err)
}
if merged.ReadingDirection != "rtl" {
t.Errorf("ReadingDirection = %q, want embedded rtl fill", merged.ReadingDirection)
}
// Sidecar ltr wins over embedded rtl
merged, err = s.mergeMetadata(epubPath, &MediaMetadata{Title: "Sidecar", ReadingDirection: "ltr"})
if err != nil {
t.Fatalf("mergeMetadata() error: %v", err)
}
if merged.ReadingDirection != "ltr" {
t.Errorf("ReadingDirection = %q, want sidecar ltr preserved", merged.ReadingDirection)
}
}
+305
View File
@@ -0,0 +1,305 @@
package utils
import (
"strconv"
"strings"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
// Per-field user-override tracking for media item metadata.
//
// metadata_overrides is a TEXT[] column on media_items holding the names of
// columns the user has customized via the metadata editor. Library scans and
// per-book rescans MUST preserve those columns (applyMetadataOverrides);
// only the reset-to-scanned-defaults action clears the set.
const (
OverrideTitle = "title"
OverrideAuthor = "author"
OverrideISBN = "isbn"
OverrideDescription = "description"
OverrideCoverImagePath = "cover_image_path"
OverrideSeries = "series"
OverrideSeriesNumber = "series_number"
OverrideTags = "tags"
OverrideAsin = "asin"
OverrideDatePublished = "date_published"
OverridePublisher = "publisher"
OverrideContributors = "contributors"
OverrideLanguage = "language"
OverrideEdition = "edition"
OverridePageCount = "page_count"
OverrideGenre = "genre"
OverrideCopyrightYear = "copyright_year"
OverrideGoodreadsID = "goodreads_id"
OverrideOpenlibraryID = "openlibrary_id"
OverrideGoogleBooksID = "google_books_id"
OverrideMangaType = "manga_type"
OverrideReadingDirection = "reading_direction"
OverrideSeriesCount = "series_count"
OverrideVolume = "volume"
OverrideImprint = "imprint"
OverrideAgeRating = "age_rating"
OverrideWebURL = "web_url"
OverrideMetadataNotes = "metadata_notes"
OverrideCommunityRating = "community_rating"
OverrideStoryArc = "story_arc"
OverrideIsBlackAndWhite = "is_black_and_white"
OverrideAlternateInfo = "alternate_info"
OverrideScanInformation = "scan_information"
OverrideSummary = "summary"
)
// hasOverride reports whether key is in the override set.
func hasOverride(overrides []string, key string) bool {
for _, k := range overrides {
if k == key {
return true
}
}
return false
}
// MergeOverrides unions existing and added, preserving order and dropping
// duplicates. Returns a non-nil slice so it always satisfies NOT NULL columns.
func MergeOverrides(existing []string, added ...string) []string {
seen := make(map[string]bool, len(existing)+len(added))
merged := make([]string, 0, len(existing)+len(added))
for _, k := range existing {
if k != "" && !seen[k] {
seen[k] = true
merged = append(merged, k)
}
}
for _, k := range added {
if k != "" && !seen[k] {
seen[k] = true
merged = append(merged, k)
}
}
return merged
}
// Normalizers turn nullable column values into comparable strings so that
// "unset" (invalid/zero) forms compare equal regardless of which side they
// come from.
func normText(t pgtype.Text) string {
if !t.Valid {
return ""
}
return t.String
}
func normInt(i pgtype.Int4) string {
if !i.Valid {
return ""
}
return strconv.FormatInt(int64(i.Int32), 10)
}
func normFloat(f pgtype.Float8) string {
if !f.Valid {
return ""
}
return strconv.FormatFloat(f.Float64, 'g', -1, 64)
}
func normBool(b pgtype.Bool) string {
if !b.Valid {
return ""
}
return strconv.FormatBool(b.Bool)
}
func normDate(d pgtype.Date) string {
if !d.Valid {
return ""
}
return d.Time.Format("2006-01-02")
}
func normStringSlice(s []string) string {
if len(s) == 0 {
return ""
}
return strings.Join(s, "\x1f")
}
func normBytes(b []byte) string {
if len(b) == 0 {
return ""
}
return string(b)
}
// detectMetadataOverridesDiff returns the keys whose value in params differs
// from the existing row. Used by the metadata editor save path to grow the
// override set with exactly the fields the user changed.
func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
var changed []string
add := func(key string, differs bool) {
if differs {
changed = append(changed, key)
}
}
add(OverrideTitle, params.Title != existing.Title)
add(OverrideAuthor, normText(params.Author) != normText(existing.Author))
add(OverrideISBN, normText(params.Isbn) != normText(existing.Isbn))
add(OverrideDescription, normText(params.Description) != normText(existing.Description))
add(OverrideSeries, normText(params.Series) != normText(existing.Series))
add(OverrideSeriesNumber, normInt(params.SeriesNumber) != normInt(existing.SeriesNumber))
add(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags))
add(OverrideAsin, normText(params.Asin) != normText(existing.Asin))
add(OverrideDatePublished, normDate(params.DatePublished) != normDate(existing.DatePublished))
add(OverridePublisher, normText(params.Publisher) != normText(existing.Publisher))
add(OverrideContributors, normStringSlice(params.Contributors) != normStringSlice(existing.Contributors))
add(OverrideLanguage, normText(params.Language) != normText(existing.Language))
add(OverrideEdition, normText(params.Edition) != normText(existing.Edition))
add(OverridePageCount, normInt(params.PageCount) != normInt(existing.PageCount))
add(OverrideGenre, normText(params.Genre) != normText(existing.Genre))
add(OverrideCopyrightYear, normInt(params.CopyrightYear) != normInt(existing.CopyrightYear))
add(OverrideGoodreadsID, normText(params.GoodreadsID) != normText(existing.GoodreadsID))
add(OverrideOpenlibraryID, normText(params.OpenlibraryID) != normText(existing.OpenlibraryID))
add(OverrideGoogleBooksID, normText(params.GoogleBooksID) != normText(existing.GoogleBooksID))
add(OverrideMangaType, normText(params.MangaType) != normText(existing.MangaType))
add(OverrideReadingDirection, normText(params.ReadingDirection) != normText(existing.ReadingDirection))
add(OverrideSeriesCount, normInt(params.SeriesCount) != normInt(existing.SeriesCount))
add(OverrideVolume, normInt(params.Volume) != normInt(existing.Volume))
add(OverrideImprint, normText(params.Imprint) != normText(existing.Imprint))
add(OverrideAgeRating, normText(params.AgeRating) != normText(existing.AgeRating))
add(OverrideWebURL, normText(params.WebUrl) != normText(existing.WebUrl))
add(OverrideMetadataNotes, normText(params.MetadataNotes) != normText(existing.MetadataNotes))
add(OverrideCommunityRating, normFloat(params.CommunityRating) != normFloat(existing.CommunityRating))
add(OverrideStoryArc, normText(params.StoryArc) != normText(existing.StoryArc))
add(OverrideIsBlackAndWhite, normBool(params.IsBlackAndWhite) != normBool(existing.IsBlackAndWhite))
add(OverrideAlternateInfo, normBytes(params.AlternateInfo) != normBytes(existing.AlternateInfo))
add(OverrideScanInformation, normText(params.ScanInformation) != normText(existing.ScanInformation))
add(OverrideSummary, normText(params.Summary) != normText(existing.Summary))
return changed
}
// DetectMetadataOverrides returns the union of the existing override set and
// any fields whose incoming (user-submitted) values differ from the stored
// row. Overrides accumulate: a field stays protected until an explicit reset,
// even if a later save reverts the value.
func DetectMetadataOverrides(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
return MergeOverrides(existing.MetadataOverrides, detectMetadataOverridesDiff(params, existing)...)
}
// ApplyMetadataOverrides restores every overridden field in params from the
// existing row so scanner updates cannot clobber user customizations. Derived
// search columns are restored together with their base column.
func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing database.MediaItems) {
o := existing.MetadataOverrides
if hasOverride(o, OverrideTitle) {
params.Title = existing.Title
}
if hasOverride(o, OverrideAuthor) {
params.Author = existing.Author
}
if hasOverride(o, OverrideISBN) {
params.Isbn = existing.Isbn
}
if hasOverride(o, OverrideDescription) {
params.Description = existing.Description
}
if hasOverride(o, OverrideCoverImagePath) {
params.CoverImagePath = existing.CoverImagePath
}
if hasOverride(o, OverrideSeries) {
params.Series = existing.Series
}
if hasOverride(o, OverrideSeriesNumber) {
params.SeriesNumber = existing.SeriesNumber
}
if hasOverride(o, OverrideTags) {
params.Tags = existing.Tags
params.TagsSearch = existing.TagsSearch
}
if hasOverride(o, OverrideAsin) {
params.Asin = existing.Asin
}
if hasOverride(o, OverrideDatePublished) {
params.DatePublished = existing.DatePublished
}
if hasOverride(o, OverridePublisher) {
params.Publisher = existing.Publisher
}
if hasOverride(o, OverrideContributors) {
params.Contributors = existing.Contributors
params.ContributorsSearch = existing.ContributorsSearch
}
if hasOverride(o, OverrideLanguage) {
params.Language = existing.Language
}
if hasOverride(o, OverrideEdition) {
params.Edition = existing.Edition
}
if hasOverride(o, OverridePageCount) {
params.PageCount = existing.PageCount
}
if hasOverride(o, OverrideGenre) {
params.Genre = existing.Genre
}
if hasOverride(o, OverrideCopyrightYear) {
params.CopyrightYear = existing.CopyrightYear
}
if hasOverride(o, OverrideGoodreadsID) {
params.GoodreadsID = existing.GoodreadsID
}
if hasOverride(o, OverrideOpenlibraryID) {
params.OpenlibraryID = existing.OpenlibraryID
}
if hasOverride(o, OverrideGoogleBooksID) {
params.GoogleBooksID = existing.GoogleBooksID
}
if hasOverride(o, OverrideMangaType) {
params.MangaType = existing.MangaType
}
if hasOverride(o, OverrideReadingDirection) {
params.ReadingDirection = existing.ReadingDirection
}
if hasOverride(o, OverrideSeriesCount) {
params.SeriesCount = existing.SeriesCount
}
if hasOverride(o, OverrideVolume) {
params.Volume = existing.Volume
}
if hasOverride(o, OverrideImprint) {
params.Imprint = existing.Imprint
}
if hasOverride(o, OverrideAgeRating) {
params.AgeRating = existing.AgeRating
}
if hasOverride(o, OverrideWebURL) {
params.WebUrl = existing.WebUrl
}
if hasOverride(o, OverrideMetadataNotes) {
params.MetadataNotes = existing.MetadataNotes
}
if hasOverride(o, OverrideCommunityRating) {
params.CommunityRating = existing.CommunityRating
}
if hasOverride(o, OverrideStoryArc) {
params.StoryArc = existing.StoryArc
}
if hasOverride(o, OverrideIsBlackAndWhite) {
params.IsBlackAndWhite = existing.IsBlackAndWhite
}
if hasOverride(o, OverrideAlternateInfo) {
params.AlternateInfo = existing.AlternateInfo
}
if hasOverride(o, OverrideScanInformation) {
params.ScanInformation = existing.ScanInformation
}
if hasOverride(o, OverrideSummary) {
params.Summary = existing.Summary
}
}
+112
View File
@@ -0,0 +1,112 @@
package utils
import (
"reflect"
"testing"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
func TestMergeOverrides(t *testing.T) {
got := MergeOverrides([]string{"title", "tags"}, "tags", "description", "")
want := []string{"title", "tags", "description"}
if !reflect.DeepEqual(got, want) {
t.Errorf("MergeOverrides() = %v, want %v", got, want)
}
if MergeOverrides(nil) == nil {
t.Error("MergeOverrides(nil) must return non-nil slice")
}
}
func TestDetectMetadataOverrides(t *testing.T) {
existing := database.MediaItems{
Title: "Scanned Title",
Description: pgtype.Text{String: "Scanned description", Valid: true},
Tags: []string{"foo", "bar"},
}
params := database.UpdateMediaItemParams{
// unchanged
Title: "Scanned Title",
// changed
Description: pgtype.Text{String: "My custom description", Valid: true},
Tags: []string{"foo", "bar"},
}
got := DetectMetadataOverrides(params, existing)
if !reflect.DeepEqual(got, []string{"description"}) {
t.Errorf("DetectMetadataOverrides() = %v, want [description]", got)
}
// Overrides accumulate: an existing override survives a later save.
existing.MetadataOverrides = []string{"publisher"}
got = DetectMetadataOverrides(params, existing)
want := []string{"publisher", "description"}
if !reflect.DeepEqual(got, want) {
t.Errorf("DetectMetadataOverrides() accumulate = %v, want %v", got, want)
}
// An untouched save detects nothing new.
noop := database.UpdateMediaItemParams{
Title: existing.Title,
Description: existing.Description,
Tags: existing.Tags,
}
got = DetectMetadataOverrides(noop, existing)
if !reflect.DeepEqual(got, []string{"publisher"}) {
t.Errorf("DetectMetadataOverrides() noop = %v, want [publisher]", got)
}
}
func TestDetectMetadataOverridesUnsetFormsEqual(t *testing.T) {
// Zero-value params must not look "changed" against NULL-ish columns.
existing := database.MediaItems{
PageCount: pgtype.Int4{Int32: 0, Valid: false},
SeriesNumber: pgtype.Int4{Int32: 5, Valid: true},
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
}
params := database.UpdateMediaItemParams{
PageCount: pgtype.Int4{Int32: 0, Valid: false},
SeriesNumber: pgtype.Int4{Int32: 0, Valid: false}, // cleared by user -> changed
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
}
got := DetectMetadataOverrides(params, existing)
if !reflect.DeepEqual(got, []string{"series_number"}) {
t.Errorf("DetectMetadataOverrides() = %v, want [series_number]", got)
}
}
func TestApplyMetadataOverrides(t *testing.T) {
existing := database.MediaItems{
Title: "Custom Title",
Description: pgtype.Text{String: "Custom desc", Valid: true},
Tags: []string{"mine"},
TagsSearch: []string{"mine"},
CoverImagePath: pgtype.Text{String: "books/x.epub.custom_cover.jpg", Valid: true},
}
params := database.UpdateMediaItemParams{
Title: "Scanned Title",
Description: pgtype.Text{String: "Scanned desc", Valid: true},
Tags: []string{"scanned"},
TagsSearch: []string{"scanned"},
CoverImagePath: pgtype.Text{String: "books/x.epub.cover.jpg", Valid: true},
}
// Only title and tags overridden; scanned description and cover win.
existing.MetadataOverrides = []string{"title", "tags"}
ApplyMetadataOverrides(&params, existing)
if params.Title != "Custom Title" {
t.Errorf("Title = %q, want %q", params.Title, "Custom Title")
}
if !reflect.DeepEqual(params.Tags, []string{"mine"}) || !reflect.DeepEqual(params.TagsSearch, []string{"mine"}) {
t.Error("Tags/TagsSearch must be restored together")
}
if params.Description.String != "Scanned desc" {
t.Errorf("Description = %q, want scanned value", params.Description.String)
}
if params.CoverImagePath.String != "books/x.epub.cover.jpg" {
t.Errorf("CoverImagePath = %q, want scanned value", params.CoverImagePath.String)
}
}
+10
View File
@@ -0,0 +1,10 @@
package version
// Version is the app version. It defaults to "dev" for local builds and is
// overridden at build time in release images via:
//
// go build -ldflags "-X bookhoard/internal/version.Version=v1.5"
//
// The release workflow passes the git tag (e.g. v1.5) as the APP_VERSION
// Docker build arg, which the Dockerfile forwards to this flag.
var Version = "dev"
+1 -1
View File
@@ -12,7 +12,7 @@
"dev": "npm run build:ts:dev && npm run build:css" "dev": "npm run build:ts:dev && npm run build:css"
}, },
"dependencies": { "dependencies": {
"@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#e448d36", "@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#e16530a",
"alpinejs": "^3.15.8", "alpinejs": "^3.15.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
+115
View File
@@ -0,0 +1,115 @@
package templates
templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<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>
<body class="theme-{ user.Theme }">
@Header(user, "/admin/archived")
<main class="p-8">
<div class="max-w-5xl">
<div class="mb-8">
<div class="flex items-center justify-between gap-4 flex-wrap">
<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);">
@Icon("library", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Archived Items</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">
Books hidden because their files are missing from disk. Reading history is
kept in case a file returns; moves are detected automatically.
if retentionDays > 0 {
Archived items are permanently deleted after { retentionDays } days
(ARCHIVE_RETENTION_DAYS).
} else {
Archived items are kept until manually purged (retention disabled).
}
</p>
</div>
if len(items) > 0 {
<button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary">
@Icon("trash", "h-4 w-4")
Purge All Archived
</button>
}
</div>
</div>
if len(items) == 0 {
<div class="card p-8 text-center">
<p style="color: var(--text-secondary)">Nothing is archived or missing. All clear.</p>
</div>
} else {
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left" style="border-color: var(--border); color: var(--text-secondary);">
<th class="px-4 py-3 font-semibold">Book</th>
<th class="px-4 py-3 font-semibold">Library</th>
<th class="px-4 py-3 font-semibold">Status</th>
<th class="px-4 py-3 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody>
for _, item := range items {
<tr class="border-b" style="border-color: color-mix(in srgb, var(--text-primary) 5%, transparent);">
<td class="px-4 py-3">
<p class="font-medium" style="color: var(--text-primary)">{ item.Title }</p>
if item.Author != "" {
<p style="color: var(--text-secondary)">{ item.Author }</p>
}
<p class="font-mono text-xs break-all" style="color: var(--text-secondary)">{ item.FilePath }</p>
</td>
<td class="px-4 py-3" style="color: var(--text-primary)">{ item.LibraryName }</td>
<td class="px-4 py-3" style="color: var(--text-secondary)">{ item.Status }</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
if item.MatchID != "" {
<button
type="button"
data-merge-source={ item.ID }
data-merge-target={ item.MatchID }
class="btn btn-ghost text-xs"
title={ "Merge this archived copy's reading data into the active copy \"" + item.MatchTitle + "\" (" + item.MatchLibraryName + ") and remove the archived entry" }
>
@Icon("copy", "h-3.5 w-3.5")
Merge
</button>
}
<button
type="button"
data-unarchive={ item.ID }
class="btn btn-ghost text-xs"
title="Restore to libraries now; if the file is still gone the next scan hides it again"
>
@Icon("refresh", "h-3.5 w-3.5")
Restore
</button>
<button
type="button"
data-delete-archived={ item.ID }
class="btn btn-ghost text-xs"
title="Delete this item and its reading history permanently"
>
@Icon("trash", "h-3.5 w-3.5")
Delete
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</main>
</body>
</html>
}
+301
View File
@@ -0,0 +1,301 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func AdminArchived(user User, items []ArchivedItem, retentionDays int) 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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
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\"><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><body class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Header(user, "/admin/archived").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"p-8\"><div class=\"max-w-5xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between gap-4 flex-wrap\"><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
}
templ_7745c5c3_Err = Icon("library", "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, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Archived Items</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Books hidden because their files are missing from disk. Reading history is kept in case a file returns; moves are detected automatically. ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if retentionDays > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Archived items are permanently deleted after ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(retentionDays)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 30, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " days (ARCHIVE_RETENTION_DAYS).")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Archived items are kept until manually purged (retention disabled).")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(items) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<button type=\"button\" onclick=\"purgeArchivedItems()\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "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, 9, "Purge All Archived</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(items) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card p-8 text-center\"><p style=\"color: var(--text-secondary)\">Nothing is archived or missing. All clear.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"card overflow-hidden\"><table class=\"w-full text-sm\"><thead><tr class=\"border-b text-left\" style=\"border-color: var(--border); color: var(--text-secondary);\"><th class=\"px-4 py-3 font-semibold\">Book</th><th class=\"px-4 py-3 font-semibold\">Library</th><th class=\"px-4 py-3 font-semibold\">Status</th><th class=\"px-4 py-3 font-semibold text-right\">Actions</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range items {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<tr class=\"border-b\" style=\"border-color: color-mix(in srgb, var(--text-primary) 5%, transparent);\"><td class=\"px-4 py-3\"><p class=\"font-medium\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 65, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Author != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 67, Col: 66}
}
_, 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, 16, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<p class=\"font-mono text-xs break-all\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 69, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></td><td class=\"px-4 py-3\" 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(item.LibraryName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 71, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td class=\"px-4 py-3\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 72, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td class=\"px-4 py-3 text-right whitespace-nowrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.MatchID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<button type=\"button\" data-merge-source=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 77, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-merge-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MatchID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 78, 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, 23, "\" class=\"btn btn-ghost text-xs\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, 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: `admin_archived.templ`, Line: 80, Col: 174}
}
_, 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, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("copy", "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, 25, "Merge</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<button type=\"button\" data-unarchive=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 88, Col: 37}
}
_, 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, 27, "\" class=\"btn btn-ghost text-xs\" title=\"Restore to libraries now; if the file is still gone the next scan hides it again\">")
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, 28, "Restore</button> <button type=\"button\" data-delete-archived=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 97, Col: 43}
}
_, 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, 29, "\" class=\"btn btn-ghost text-xs\" title=\"Delete this item and its reading history permanently\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "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, 30, "Delete</button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+16 -16
View File
@@ -59,7 +59,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("conflict-" + conflict.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("conflict-" + conflict.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 27, Col: 53} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 27, Col: 53}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -80,7 +80,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d copies", len(conflict.Items))) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d copies", len(conflict.Items)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 33, Col: 87} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 33, Col: 87}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -93,7 +93,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.LibraryName) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.LibraryName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 36, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 36, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -106,7 +106,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.SHAShort) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.SHAShort)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 38, Col: 55} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 38, Col: 55}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -119,7 +119,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflict.ID + "/resolve") templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflict.ID + "/resolve")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 46, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 46, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -132,7 +132,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflict.ID) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflict.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 48, Col: 43} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 48, Col: 43}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -194,7 +194,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 71, Col: 82} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 71, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -212,7 +212,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 73, Col: 82} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 73, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -230,7 +230,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 75, Col: 89} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 75, Col: 89}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -243,7 +243,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f MB", float64(item.FileSize)/(1024*1024))) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f MB", float64(item.FileSize)/(1024*1024)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 77, Col: 64} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 77, Col: 64}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -261,7 +261,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 79, Col: 85} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 79, Col: 85}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -279,7 +279,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 81, Col: 43} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 81, Col: 43}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -297,7 +297,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflictID + "/resolve") templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflictID + "/resolve")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 87, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 87, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -310,7 +310,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"action": "keep", "keep_uuid": "%s"}`, item.ID)) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"action": "keep", "keep_uuid": "%s"}`, item.ID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 88, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 88, Col: 75}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -323,7 +323,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflictID) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflictID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 89, Col: 41} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 89, Col: 41}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -383,7 +383,7 @@ func AdminHashConflicts(user User, conflicts []HashConflictData) templ.Component
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String()) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+42 -3
View File
@@ -1,6 +1,6 @@
package templates package templates
templ AdminLibrary(user User, libraries []LibraryData, users []User) { templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -34,6 +34,28 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
</button> </button>
</div> </div>
</div> </div>
if archivedCount > 0 {
<div class="card p-4 mb-6 flex items-center justify-between gap-4 flex-wrap">
<div>
<p class="font-semibold" style="color: var(--text-primary)">
<a href="/admin/archived" class="hover:underline" style="color: var(--text-primary); text-decoration: none;">
Archived items: { archivedCount }
</a>
</p>
<p class="text-sm" style="color: var(--text-secondary)">
Files missing from disk for two consecutive scans. Reading history is kept
unless purged; items return automatically if their files come back.
</p>
</div>
<div class="flex items-center gap-2">
<a href="/admin/archived" class="btn btn-secondary">View Archived</a>
<button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary">
@Icon("trash", "h-4 w-4")
Purge Archived Now
</button>
</div>
</div>
}
<div id="libraries-container"> <div id="libraries-container">
@LibraryList(user, libraries, users) @LibraryList(user, libraries, users)
</div> </div>
@@ -129,10 +151,18 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
<p class="mb-6" style="color: var(--text-primary)"> <p class="mb-3" style="color: var(--text-primary)">
Are you sure you want to delete <strong id="delete-library-name"></strong>? Are you sure you want to delete <strong id="delete-library-name"></strong>?
This will remove the library and all its folder mappings. Media files will not be deleted.
</p> </p>
<ul class="mb-6 space-y-2 text-sm list-disc list-inside" style="color: var(--text-primary)">
<li>
<strong>Permanently removed, no archive or undo:</strong>
the library, its folder mappings, and all book records — with their reading progress, highlights, notes, bookmarks, and ratings.
</li>
<li>
<strong>Not touched:</strong> media files on disk.
</li>
</ul>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button> <button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button>
<button type="button" id="delete-library-confirm" class="btn btn-danger"> <button type="button" id="delete-library-confirm" class="btn btn-danger">
@@ -179,6 +209,15 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
htmx.process(content); htmx.process(content);
document.getElementById('folder-browser-modal').classList.remove('hidden'); document.getElementById('folder-browser-modal').classList.remove('hidden');
} }
// Close the delete-library modal after a successful delete: the
// htmx swap already removes the deleted row from the libraries
// list, but the modal lives outside the swapped container and
// would otherwise stay open.
document.getElementById('delete-library-confirm').addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.successful) {
document.getElementById('delete-library-modal').classList.add('hidden');
}
});
</script> </script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -70,7 +70,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 36, Col: 92} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 36, Col: 92}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -97,7 +97,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 45, Col: 94} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 45, Col: 94}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -124,7 +124,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 54, Col: 91} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 54, Col: 91}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -166,7 +166,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 70, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 70, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -179,7 +179,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 73, Col: 98} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 73, Col: 98}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -192,7 +192,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 96} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 74, Col: 96}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -205,7 +205,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 140} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 76, Col: 140}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -218,7 +218,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 144} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 77, Col: 144}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -231,7 +231,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 139} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 78, Col: 139}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -244,7 +244,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 149} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 79, Col: 149}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -262,7 +262,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 84, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 84, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -280,7 +280,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 86, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 86, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -298,7 +298,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 88, Col: 66} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 88, Col: 66}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -321,7 +321,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve") templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 95, Col: 113} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 95, Col: 113}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -334,7 +334,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 43} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 96, Col: 43}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+20 -1
View File
@@ -1,6 +1,9 @@
package templates package templates
import "fmt" import (
"bookhoard/internal/version"
"fmt"
)
templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) { templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) {
<!DOCTYPE html> <!DOCTYPE html>
@@ -111,6 +114,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
@ScanSettingsSection(scanSettings) @ScanSettingsSection(scanSettings)
@TunableSettingsSection(liveGroups, false) @TunableSettingsSection(liveGroups, false)
@TunableSettingsSection(restartGroups, true) @TunableSettingsSection(restartGroups, true)
@AboutSection()
</div> </div>
</main> </main>
</body> </body>
@@ -255,3 +259,18 @@ templ TunableSettingRow(e SettingEntry) {
<span id={ "status-" + e.Key } class="text-xs w-24 text-right" style="color: var(--text-secondary)"></span> <span id={ "status-" + e.Key } class="text-xs w-24 text-right" style="color: var(--text-secondary)"></span>
</div> </div>
} }
// AboutSection shows build metadata, rendered at the bottom of the System
// Settings page. Add future rows (disk space, uptime, Go version, …) to the
// list below.
templ AboutSection() {
<div class="mt-6 card p-6">
<div class="flex items-center gap-2 mb-4">
@Icon("info", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">About</h3>
</div>
<div class="space-y-2 text-sm" style="color: var(--text-secondary);">
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Version:</span> { version.Version }</p>
</div>
</div>
}
+78 -18
View File
@@ -8,7 +8,10 @@ package templates
import "github.com/a-h/templ" import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime" import templruntime "github.com/a-h/templ/runtime"
import "fmt" import (
"bookhoard/internal/version"
"fmt"
)
func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) templ.Component { func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
@@ -67,7 +70,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 31, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 34, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -93,7 +96,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"]) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 45, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 48, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -370,7 +373,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 106, Col: 145} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 109, Col: 145}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -383,7 +386,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 107, Col: 144} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 110, Col: 144}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -396,7 +399,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 108, Col: 152} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 111, Col: 152}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -418,6 +421,10 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = AboutSection().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div></main></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div></main></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
@@ -472,7 +479,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds)) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 154, Col: 69} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 158, Col: 69}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -564,7 +571,7 @@ func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.C
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 194, Col: 112} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 198, Col: 112}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -622,7 +629,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 209, Col: 94} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 213, Col: 94}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -640,7 +647,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 211, Col: 74} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 215, Col: 74}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -658,7 +665,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 213, Col: 74} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 217, Col: 74}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -676,7 +683,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 219, Col: 33} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 223, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -689,7 +696,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 223, Col: 48} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 227, Col: 48}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -732,7 +739,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 233, Col: 20} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 237, Col: 20}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -750,7 +757,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 235, Col: 17} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 239, Col: 17}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -769,7 +776,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 238, Col: 17} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 242, Col: 17}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -792,7 +799,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 246, Col: 20} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 250, Col: 20}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -818,7 +825,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 255, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 259, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -832,4 +839,57 @@ func TunableSettingRow(e SettingEntry) templ.Component {
}) })
} }
// AboutSection shows build metadata, rendered at the bottom of the System
// Settings page. Add future rows (disk space, uptime, Go version, …) to the
// list below.
func AboutSection() 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_Var22 := templ.GetChildren(ctx)
if templ_7745c5c3_Var22 == nil {
templ_7745c5c3_Var22 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("info", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">About</h3></div><div class=\"space-y-2 text-sm\" style=\"color: var(--text-secondary);\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Version:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 273, Col: 134}
}
_, 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, 109, "</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate var _ = templruntime.GeneratedTemplate
+4 -4
View File
@@ -60,7 +60,7 @@ func Admin(user User, stats AdminStats) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 32, Col: 92} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 32, Col: 92}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -81,7 +81,7 @@ func Admin(user User, stats AdminStats) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 39, Col: 90} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 39, Col: 90}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -102,7 +102,7 @@ func Admin(user User, stats AdminStats) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 46, Col: 89} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 46, Col: 89}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -123,7 +123,7 @@ func Admin(user User, stats AdminStats) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 53, Col: 91} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 53, Col: 91}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+10 -10
View File
@@ -57,7 +57,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 36} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 42, Col: 36}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -78,7 +78,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 50, Col: 97} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 50, Col: 97}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -101,7 +101,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 59, Col: 80} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 59, Col: 80}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -124,7 +124,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 77, Col: 53} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 77, Col: 53}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -137,7 +137,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 78, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 78, Col: 51}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -170,7 +170,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 87, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 87, Col: 47}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -188,7 +188,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone)) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 92, Col: 126} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 92, Col: 126}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -201,7 +201,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal") templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 98, Col: 67} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 98, Col: 67}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -240,7 +240,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 117, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 117, Col: 57}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -253,7 +253,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 118, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 118, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+13 -13
View File
@@ -42,7 +42,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 8, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 8, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -60,7 +60,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 10, Col: 65} return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 10, Col: 65}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -78,7 +78,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 12, Col: 120} return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 12, Col: 120}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -96,7 +96,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 14, Col: 69} return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 14, Col: 69}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -114,7 +114,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 16, Col: 88} return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 16, Col: 88}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -127,7 +127,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 24, Col: 131} return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 24, Col: 131}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -140,7 +140,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 28, Col: 128} return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 28, Col: 128}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -179,7 +179,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 45, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 45, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -197,7 +197,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 47, Col: 65} return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 47, Col: 65}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -215,7 +215,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 120} return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 49, Col: 120}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -233,7 +233,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 51, Col: 69} return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 51, Col: 69}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -251,7 +251,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 53, Col: 88} return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 53, Col: 88}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -304,7 +304,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 70, Col: 136} return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 70, Col: 136}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+17 -7
View File
@@ -107,10 +107,12 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span> <span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
} }
</button> </button>
if user.Role == "admin" {
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5"> <button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
@Icon("edit", "h-4 w-4") @Icon("edit", "h-4 w-4")
<span>Edit</span> <span>Edit</span>
</button> </button>
}
</div> </div>
<!-- Rating --> <!-- Rating -->
<div class="mb-5" @mouseleave="ratingHover = 0"> <div class="mb-5" @mouseleave="ratingHover = 0">
@@ -370,12 +372,18 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Format</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Format</p>
<p style="color: var(--text-primary)">{ book.MimeType.String }</p> <p style="color: var(--text-primary)">{ book.MimeType.String }</p>
</div> </div>
if book.FileSize.Valid && book.FileSize.Int64 > 0 { if book.FileSize.Valid && book.FileSize.Int64 > 0 {
<div> <div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p> <p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p>
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p> <p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
</div> </div>
} }
if user.Role == "admin" {
<div class="md:col-span-2 lg:col-span-3">
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Location</p>
<p class="font-mono text-xs break-all select-all" style="color: var(--text-secondary)">{ book.FileLocation }</p>
</div>
}
</div> </div>
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid { if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
<div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"> <div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
@@ -426,7 +434,9 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
</div> </div>
@ProgressSyncModal(user, book) @ProgressSyncModal(user, book)
@NotesHighlightsModal(user, book) @NotesHighlightsModal(user, book)
@MetadataEditorModal(book) if user.Role == "admin" {
@MetadataEditorModal(book)
}
@ErrorToast(errorMessage) @ErrorToast(errorMessage)
</body> </body>
</html> </html>
+41 -41
View File
@@ -290,25 +290,6 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<input type="file" id="cover-upload-input" accept="image/jpeg,image/png,image/webp" class="hidden" <input type="file" id="cover-upload-input" accept="image/jpeg,image/png,image/webp" class="hidden"
@change="handleCoverUpload($event)" /> @change="handleCoverUpload($event)" />
<div class="w-64 space-y-2"> <div class="w-64 space-y-2">
<button
type="button"
class="btn btn-primary w-full"
@click="generateCover()"
x-show="coverGenerating"
disabled
>
@Icon("refresh", "h-4 w-4")
Generating...
</button>
<button
type="button"
class="btn btn-primary w-full"
@click="generateCover()"
x-show="!coverGenerating"
>
@Icon("refresh", "h-4 w-4")
Generate Cover
</button>
<button <button
type="button" type="button"
class="btn btn-ghost w-full" class="btn btn-ghost w-full"
@@ -593,34 +574,53 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Contributors (comma-separated)</label> <label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Contributors (comma-separated)</label>
<input type="text" name="contributors" value={ stringSliceToString(book.Contributors) } class="input" /> <input type="text" name="contributors" value={ stringSliceToString(book.Contributors) } class="input" />
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Format</label> <label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Format</label>
<input type="text" value={ textToString(book.MimeType) } readonly <input type="text" value={ textToString(book.MimeType) } readonly
class="input opacity-60" /> class="input opacity-60" />
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Size</label>
<input type="text" value={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly
class="input opacity-60" />
</div>
</div> </div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Size</label>
<input type="text" value={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly
class="input opacity-60" />
</div>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Path</label>
<input type="text" value={ book.FileLocation } readonly
class="input opacity-60 font-mono text-xs" />
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="flex items-center justify-between p-6 border-t flex-shrink-0" style="border-color: var(--border);"> <div class="flex items-center justify-between p-6 border-t flex-shrink-0" style="border-color: var(--border);">
<button <div class="flex items-center gap-2">
@click="rescanBook()" <button
:disabled="rescanning" @click="rescanBook()"
class="btn btn-secondary" :disabled="rescanning"
> class="btn btn-secondary"
<span x-show="!rescanning" class="inline-flex items-center gap-2">@Icon("sync", "h-4 w-4")<span>Rescan</span></span> >
<svg x-show="rescanning" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none"> <span x-show="!rescanning" class="inline-flex items-center gap-2">@Icon("sync", "h-4 w-4")<span>Rescan</span></span>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <svg x-show="rescanning" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
</svg> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</button> </svg>
</button>
<button
@click="resetMetadata()"
:disabled="resetting"
class="btn btn-secondary"
title="Discard all manual edits and restore scanned metadata"
>
<span x-show="!resetting" class="inline-flex items-center gap-2">@Icon("refresh", "h-4 w-4")<span>Reset to Scanned</span></span>
<svg x-show="resetting" 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"> <div class="flex space-x-3">
<button <button
@click="hideMetadataEditor()" @click="hideMetadataEditor()"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -148,14 +148,20 @@ templ BookShelf(
} else { } else {
<option value="">All Books ({ TotalMediaCount(libraries) })</option> <option value="">All Books ({ TotalMediaCount(libraries) })</option>
} }
for _, lib := range libraries { for _, lib := range libraries {
if lib.Offline {
if lib.ID == currentLibraryID { if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option> <option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
} else { } else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option> <option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
} }
} else if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
} }
} }
}
</select> </select>
</div> </div>
<div> <div>
+159 -69
View File
@@ -97,7 +97,7 @@ func BookShelf(
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID)) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
if templ_7745c5c3_Err != nil { 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: `bookshelf.templ`, Line: 93, Col: 135}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -110,7 +110,7 @@ func BookShelf(
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
if templ_7745c5c3_Err != nil { 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: `bookshelf.templ`, Line: 95, Col: 25}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -173,7 +173,7 @@ func BookShelf(
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries)) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 147, Col: 77} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 147, Col: 77}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -191,7 +191,7 @@ func BookShelf(
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries)) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 149, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 149, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -203,98 +203,188 @@ func BookShelf(
} }
} }
for _, lib := range libraries { for _, lib := range libraries {
if lib.ID == currentLibraryID { if lib.Offline {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<option value=\"") if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" selected>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 77}
}
_, 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, 24, ") — storage offline</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 35}
}
_, 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, 26, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 68}
}
_, 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, 28, ") — storage offline</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 34}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" selected>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" selected>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 56}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " (") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " (")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { 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: `bookshelf.templ`, Line: 159, Col: 76}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ")</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ")</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 34}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 48} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 47}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " (") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " (")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { 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: `bookshelf.templ`, Line: 161, Col: 67}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ")</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ")</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -303,53 +393,53 @@ func BookShelf(
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if errorMessage != "" { if errorMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 269, Col: 59} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 275, Col: 59}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if count > 0 { if count > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit)) templ_7745c5c3_Var19, 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 { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 278, Col: 126} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 284, Col: 126}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if offset <= 0 { if offset <= 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " disabled") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " disabled")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -357,43 +447,43 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 287, Col: 32} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 293, Col: 32}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit)) 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 { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 292, Col: 126} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 298, Col: 126}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if offset+limit >= count { if offset+limit >= count {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " disabled") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " disabled")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "><span>Next</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "><span>Next</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -401,12 +491,12 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -414,7 +504,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+9 -9
View File
@@ -41,7 +41,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(pageTitle) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(pageTitle)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 14, Col: 21} return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 14, Col: 21}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -62,7 +62,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var3 templ.SafeURL var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 22} return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 22, Col: 22}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -83,7 +83,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 24, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 24, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -96,7 +96,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 17} return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 17}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -109,7 +109,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 32} return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 32}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -122,7 +122,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 93} return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 33, Col: 93}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -135,7 +135,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books))) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 34, Col: 125} return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 34, Col: 125}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -168,7 +168,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 44, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -181,7 +181,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 46, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 46, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+7 -7
View File
@@ -72,7 +72,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 23, Col: 49} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 23, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -85,7 +85,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 26, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 26, Col: 57}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -98,7 +98,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 32, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 32, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -111,7 +111,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 45, Col: 31} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 45, Col: 31}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -124,7 +124,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 58, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 58, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -137,7 +137,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 74, Col: 86} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 74, Col: 86}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -155,7 +155,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 116, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 116, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+4 -4
View File
@@ -36,7 +36,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 9, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 9, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -65,7 +65,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 176} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 176}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -78,7 +78,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil { 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: `collection_rules.templ`, Line: 25, Col: 90}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -99,7 +99,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 45, Col: 67} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 45, Col: 67}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+29 -29
View File
@@ -97,7 +97,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 71, Col: 82} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 71, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -110,7 +110,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 74, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 74, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -123,7 +123,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 77, Col: 41} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 77, Col: 41}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -136,7 +136,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal") templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 80, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 80, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -157,7 +157,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "") templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 89, Col: 56} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 89, Col: 56}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -178,7 +178,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 99, Col: 92} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 99, Col: 92}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -191,7 +191,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 100, Col: 81} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 100, Col: 81}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -246,7 +246,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 117, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 117, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -279,7 +279,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 132, Col: 166} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 132, Col: 166}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -292,7 +292,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 134, Col: 105} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 134, Col: 105}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -305,7 +305,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 135, Col: 87} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 135, Col: 87}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -355,7 +355,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 84} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 84}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -368,7 +368,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 112} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 112}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -381,7 +381,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 147} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 147}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -404,7 +404,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var17 templ.SafeURL var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 194, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 194, Col: 47}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -417,7 +417,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 196, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 196, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -435,7 +435,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 201, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 201, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -453,7 +453,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var20 templ.SafeURL var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 206, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 206, Col: 47}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -471,7 +471,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 209, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 209, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -551,7 +551,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 385, Col: 26} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 385, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -564,7 +564,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 386, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 386, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -577,7 +577,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 387, Col: 39} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 387, Col: 39}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -620,7 +620,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var26 string var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')") templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 397, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 397, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -638,7 +638,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var27 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 401, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -651,7 +651,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var28 string var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 401, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -669,7 +669,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var29 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 403, Col: 61} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 403, Col: 61}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -687,7 +687,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var30 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')") templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 406, Col: 72} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 406, Col: 72}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -700,7 +700,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var31 string var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')") templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 411, Col: 72} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 411, Col: 72}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -713,7 +713,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var32 string var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 418, Col: 87} return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 418, Col: 87}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+9 -9
View File
@@ -54,7 +54,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(total) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(total)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 45, Col: 82} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 45, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -67,7 +67,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 49, Col: 89} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 89}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -80,7 +80,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 53, Col: 97} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 53, Col: 97}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -128,7 +128,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 111, Col: 72} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 111, Col: 72}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -141,7 +141,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 117, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 117, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -162,7 +162,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 125, Col: 105} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 125, Col: 105}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -175,7 +175,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 126, Col: 96} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 126, Col: 96}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -188,7 +188,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone)) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 133, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 133, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -206,7 +206,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 135, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 135, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+2 -2
View File
@@ -65,7 +65,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { 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: `custom_section.templ`, Line: 74, Col: 31}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -78,7 +78,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { 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: `custom_section.templ`, Line: 74, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+29 -29
View File
@@ -104,7 +104,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 40, Col: 33} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 40, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -117,7 +117,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 41, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 41, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -130,7 +130,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 130} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 45, Col: 130}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -143,7 +143,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 100} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 47, Col: 100}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -161,7 +161,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 49, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 49, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -184,7 +184,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var8 templ.SafeURL var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 55, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 55, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -210,7 +210,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 68, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 68, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -231,7 +231,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 39} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 76, Col: 39}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -249,7 +249,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MediaItemID) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MediaItemID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 81, Col: 92} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 81, Col: 92}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -289,7 +289,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 95, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -339,7 +339,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var14 templ.SafeURL var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 108, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -357,7 +357,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 115, Col: 31} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 115, Col: 31}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -370,7 +370,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 116, Col: 22} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 116, Col: 22}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -388,7 +388,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 124, Col: 22} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 124, Col: 22}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -406,7 +406,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 117} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 130, Col: 117}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -419,7 +419,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 17} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 131, Col: 17}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -437,7 +437,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 134, Col: 100} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 134, Col: 100}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -450,7 +450,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 135, Col: 19} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 135, Col: 19}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -473,7 +473,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var22 templ.SafeURL var templ_7745c5c3_Var22 templ.SafeURL
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 143, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 143, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -486,7 +486,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 145, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 145, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -512,7 +512,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var24 templ.SafeURL var templ_7745c5c3_Var24 templ.SafeURL
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 152, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 152, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -525,7 +525,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 154, Col: 38} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 154, Col: 38}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -593,7 +593,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var27 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 184, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 184, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -606,7 +606,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var28 string var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem)) templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 185, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -627,7 +627,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var29 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon) templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 190, Col: 43} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 190, Col: 43}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -640,7 +640,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var30 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 192, Col: 84} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 192, Col: 84}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -668,7 +668,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var31 string var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 202, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 202, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -701,7 +701,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var32 string var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection) templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 128} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 228, Col: 128}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -714,7 +714,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var33 string var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection) templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 235, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+16 -16
View File
@@ -111,7 +111,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("showShelfMappings('" + device.ID.String() + "')") templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("showShelfMappings('" + device.ID.String() + "')")
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 64, Col: 77}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -132,7 +132,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')") templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')")
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 67, Col: 77}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -153,7 +153,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName)
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 72, Col: 102}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -166,7 +166,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType)
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 73, Col: 89}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -199,7 +199,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone)) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone))
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 86, Col: 104}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -227,7 +227,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone)) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone))
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 94, Col: 104}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -255,7 +255,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken)
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 110, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -282,7 +282,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken)
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 132, Col: 38}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -308,7 +308,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token") templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token")
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 147, Col: 78}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -349,7 +349,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName)
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 168, Col: 87}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -362,7 +362,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType)
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 170, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -375,7 +375,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt)
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 170, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -388,7 +388,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')") templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')")
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 174, Col: 72}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -401,7 +401,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')") templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')")
if templ_7745c5c3_Err != nil { 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: `devices.templ`, Line: 177, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -440,7 +440,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 242, Col: 148} return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 242, Col: 148}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -453,7 +453,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')") templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 244, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 244, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+15 -15
View File
@@ -38,7 +38,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 11, Col: 21} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 11, Col: 21}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -60,7 +60,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String()) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -102,7 +102,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 50, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 50, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -133,7 +133,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var6 templ.SafeURL var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 56, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -146,7 +146,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 57, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 57, Col: 75}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -159,7 +159,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 58, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 58, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -187,7 +187,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var9 templ.SafeURL var templ_7745c5c3_Var9 templ.SafeURL
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 65, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 65, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -200,7 +200,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 66, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 66, Col: 75}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -213,7 +213,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 67, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 67, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -257,7 +257,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var12 templ.SafeURL var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 82, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -270,7 +270,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 129} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 82, Col: 129}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -293,7 +293,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 104} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 86, Col: 104}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -324,7 +324,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var15 templ.SafeURL var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 96, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -337,7 +337,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);") templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 105} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 98, Col: 105}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -350,7 +350,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 100, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+2 -2
View File
@@ -36,7 +36,7 @@ func ErrorPage(message string, errorType string) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 84, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `error.templ`, Line: 84, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -49,7 +49,7 @@ func ErrorPage(message string, errorType string) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 90, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `error.templ`, Line: 90, Col: 75}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+2 -2
View File
@@ -36,7 +36,7 @@ func FilterItem(id string, name string) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(id) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 6, Col: 21} return templ.Error{Err: templ_7745c5c3_Err, FileName: `filter_item.templ`, Line: 6, Col: 21}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -49,7 +49,7 @@ func FilterItem(id string, name string) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(name) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 14, Col: 9} return templ.Error{Err: templ_7745c5c3_Err, FileName: `filter_item.templ`, Line: 14, Col: 9}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+22 -22
View File
@@ -53,7 +53,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String()) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -83,7 +83,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var4).String()) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var4).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -113,7 +113,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String()) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -143,7 +143,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String()) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -173,7 +173,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String()) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -203,7 +203,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var12).String()) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var12).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -260,7 +260,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeTheme('" + opt.Name + "'); openPanel = null") templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeTheme('" + opt.Name + "'); openPanel = null")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 103, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 103, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -273,7 +273,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 105, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 105, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -286,7 +286,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color) templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 108, Col: 130} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 108, Col: 130}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -299,7 +299,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 109, Col: 50} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 109, Col: 50}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -321,7 +321,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String()) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -352,7 +352,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')") templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 120, Col: 55} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 120, Col: 55}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -365,7 +365,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 122, Col: 26} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 122, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -388,7 +388,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;") templ_7745c5c3_Var22, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 128, Col: 191} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 128, Col: 191}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -406,7 +406,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 130, Col: 48} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 130, Col: 48}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -446,7 +446,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var24).String()) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var24).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -476,7 +476,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var27 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var26).String()) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var26).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -506,7 +506,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var29 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var28).String()) templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var28).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -536,7 +536,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var31 string var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var30).String()) templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var30).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -566,7 +566,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var33 string var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var32).String()) templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var32).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -646,7 +646,7 @@ func SidebarUserMenu(user User) templ.Component {
var templ_7745c5c3_Var35 string var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 220, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 220, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -730,7 +730,7 @@ func SidebarSignIn(currentPath string) templ.Component {
var templ_7745c5c3_Var37 string var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath) templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 268, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 268, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+1 -1
View File
@@ -45,7 +45,7 @@ func Icon(name string, extra string) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String()) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/icons.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `icons.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+2 -2
View File
@@ -41,7 +41,7 @@ func Index(loggedIn bool) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 24, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -54,7 +54,7 @@ func Index(loggedIn bool) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 24, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+10 -4
View File
@@ -13,14 +13,20 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ..
name="library_id" name="library_id"
class="input w-auto py-1.5 pr-8 cursor-pointer" class="input w-auto py-1.5 pr-8 cursor-pointer"
> >
<option value="">All Libraries ({ TotalMediaCount(libData) })</option> <option value="">All Libraries ({ TotalMediaCount(libData) })</option>
for _, lib := range libData { for _, lib := range libData {
if lib.Offline {
if lib.ID == currentLibraryID { if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option> <option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
} else { } else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option> <option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
} }
} else if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
} }
}
</select> </select>
</div> </div>
if len(actions) > 0 { if len(actions) > 0 {
+134 -44
View File
@@ -36,7 +36,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData)) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `library_switcher.templ`, Line: 16, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -47,102 +47,192 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, lib := range libData { for _, lib := range libData {
if lib.ID == currentLibraryID { if lib.Offline {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"") if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `library_switcher.templ`, Line: 20, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" selected>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `library_switcher.templ`, Line: 20, Col: 51}
}
_, 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, 5, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `library_switcher.templ`, Line: 20, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ") — storage offline</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `library_switcher.templ`, Line: 22, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `library_switcher.templ`, Line: 22, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `library_switcher.templ`, Line: 22, Col: 62}
}
_, 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, 10, ") — storage offline</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `library_switcher.templ`, Line: 25, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" selected>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" selected>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `library_switcher.templ`, Line: 25, Col: 50}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " (") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " (")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 71} return templ.Error{Err: templ_7745c5c3_Err, FileName: `library_switcher.templ`, Line: 25, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ")</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ")</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `library_switcher.templ`, Line: 27, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `library_switcher.templ`, Line: 27, Col: 41}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " (") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " (")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `library_switcher.templ`, Line: 27, Col: 61}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ")</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ")</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</select></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</select></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(actions) > 0 { if len(actions) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex items-center gap-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"flex items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -152,12 +242,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -181,12 +271,12 @@ func DashboardActions() templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx) templ_7745c5c3_Var15 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil { if templ_7745c5c3_Var15 == nil {
templ_7745c5c3_Var9 = templ.NopComponent templ_7745c5c3_Var15 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button data-action=\"open-dashboard-settings\" class=\"icon-btn\" title=\"Customize Dashboard\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<button data-action=\"open-dashboard-settings\" class=\"icon-btn\" title=\"Customize Dashboard\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -194,7 +284,7 @@ func DashboardActions() templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button> <button data-action=\"reload-page\" class=\"icon-btn\" title=\"Refresh\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</button> <button data-action=\"reload-page\" class=\"icon-btn\" title=\"Refresh\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -202,7 +292,7 @@ func DashboardActions() templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+2 -2
View File
@@ -41,7 +41,7 @@ func Login(sessionExpired bool, deleted bool) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 24, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `login.templ`, Line: 24, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -54,7 +54,7 @@ func Login(sessionExpired bool, deleted bool) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 24, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `login.templ`, Line: 24, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+6 -6
View File
@@ -36,7 +36,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 5, Col: 20} return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 5, Col: 20}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -57,7 +57,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 22, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 22, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -70,7 +70,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 31, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 31, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -83,7 +83,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 40, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 40, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -96,7 +96,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 50, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 50, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -475,7 +475,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 189, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 189, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+2 -2
View File
@@ -44,7 +44,7 @@ func ProfileModal(user User) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 14, Col: 22} return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_modal.templ`, Line: 14, Col: 22}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -57,7 +57,7 @@ func ProfileModal(user User) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 14, Col: 38} return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_modal.templ`, Line: 14, Col: 38}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+15 -15
View File
@@ -74,7 +74,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 44, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 44, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -87,7 +87,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var3 templ.SafeURL var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID.String()) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID.String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 54, Col: 56} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 54, Col: 56}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -100,7 +100,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 58, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 58, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -118,7 +118,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 61, Col: 91} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 61, Col: 91}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -136,7 +136,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", item.ProgressPercentage)) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", item.ProgressPercentage))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 64, Col: 127} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 64, Col: 127}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -149,7 +149,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width: " + fmt.Sprintf("%.1f", item.ProgressPercentage) + "%; background-color: var(--accent);") templ_7745c5c3_Var7, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width: " + fmt.Sprintf("%.1f", item.ProgressPercentage) + "%; background-color: var(--accent);")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 68, Col: 162} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 68, Col: 162}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -164,7 +164,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Page %d of %d (est.)", item.CurrentPage, item.EstimatedPages)) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Page %d of %d (est.)", item.CurrentPage, item.EstimatedPages))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 77, Col: 90} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 77, Col: 90}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -174,7 +174,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f%%", item.ProgressPercentage)) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f%%", item.ProgressPercentage))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 79, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 79, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -185,7 +185,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", item.CurrentPage, item.TotalPages)) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", item.CurrentPage, item.TotalPages))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 82, Col: 72} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 82, Col: 72}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -199,7 +199,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.LastUpdated) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.LastUpdated)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 88, Col: 95} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 88, Col: 95}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -212,7 +212,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceIcon) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceIcon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 93, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 93, Col: 51}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -225,7 +225,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 94, Col: 98} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 94, Col: 98}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -251,7 +251,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 101, Col: 92} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 101, Col: 92}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -264,7 +264,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceType) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceType)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 102, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 102, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -291,7 +291,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 108, Col: 36} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 108, Col: 36}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+13 -13
View File
@@ -62,7 +62,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.PendingCount) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.PendingCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 41, Col: 97} return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 41, Col: 97}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -75,7 +75,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ProcessingCount) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ProcessingCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 45, Col: 97} return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 45, Col: 97}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -88,7 +88,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.CompletedCount) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.CompletedCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 49, Col: 99} return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 49, Col: 99}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -101,7 +101,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.FailedCount) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.FailedCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 53, Col: 95} return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 53, Col: 95}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -149,7 +149,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 130, Col: 65} return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 130, Col: 65}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -162,7 +162,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.SyncType) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.SyncType)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 131, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 131, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -175,7 +175,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Priority) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Priority)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 133, Col: 71} return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 133, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -188,7 +188,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceID) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 136, Col: 34} return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 136, Col: 34}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -206,7 +206,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(*item.MediaTitle) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(*item.MediaTitle)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 138, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 138, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -220,7 +220,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Attempts) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Attempts)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 142, Col: 39} return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 142, Col: 39}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -233,7 +233,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.MaxAttempts) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.MaxAttempts)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 142, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 142, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -246,7 +246,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.CreatedAt) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.CreatedAt)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 143, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 143, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -272,7 +272,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(*item.ErrorMessage) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(*item.ErrorMessage)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 148, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `queue.templ`, Line: 148, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+95 -98
View File
@@ -16,6 +16,7 @@ func readerInitExpr(metadata ReaderMetadata) string {
"formatGroup": metadata.FormatGroup, "formatGroup": metadata.FormatGroup,
"readingDirection": metadata.ReadingDirection, "readingDirection": metadata.ReadingDirection,
"mangaType": metadata.MangaType, "mangaType": metadata.MangaType,
"treatAsComic": metadata.TreatAsComic,
} }
jsonBytes, _ := json.Marshal(config) jsonBytes, _ := json.Marshal(config)
return fmt.Sprintf("initReader(%s)", string(jsonBytes)) return fmt.Sprintf("initReader(%s)", string(jsonBytes))
@@ -23,7 +24,7 @@ func readerInitExpr(metadata ReaderMetadata) string {
templ Reader(user User, metadata ReaderMetadata) { templ Reader(user User, metadata ReaderMetadata) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="overscroll-none">
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
@@ -38,7 +39,7 @@ templ Reader(user User, metadata ReaderMetadata) {
<body <body
x-data="readerShell" x-data="readerShell"
x-init={ readerInitExpr(metadata) } x-init={ readerInitExpr(metadata) }
class={ "theme-" + user.Theme + " h-screen overflow-hidden" } class={ "theme-" + user.Theme + " h-screen overflow-hidden overscroll-none" }
> >
<!-- Reading surface: edge-to-edge. Chrome overlays translucently; <!-- Reading surface: edge-to-edge. Chrome overlays translucently;
drawers and scrim float above everything. --> drawers and scrim float above everything. -->
@@ -68,9 +69,7 @@ templ Reader(user User, metadata ReaderMetadata) {
</svg> </svg>
</div> </div>
</div> </div>
@ReaderChrome(metadata) @ReaderChrome(metadata)
<!-- Drawer scrim --> <!-- Drawer scrim -->
<div <div
x-show="tocOpen || settingsOpen || bookmarksOpen || searchOpen" x-show="tocOpen || settingsOpen || bookmarksOpen || searchOpen"
@@ -79,7 +78,6 @@ templ Reader(user User, metadata ReaderMetadata) {
class="drawer-scrim" class="drawer-scrim"
aria-hidden="true" aria-hidden="true"
></div> ></div>
<!-- TOC drawer (left) --> <!-- TOC drawer (left) -->
<div <div
id="toc-drawer" id="toc-drawer"
@@ -96,7 +94,6 @@ templ Reader(user User, metadata ReaderMetadata) {
> >
@ReaderTOCDrawer() @ReaderTOCDrawer()
</div> </div>
<!-- Settings drawer (right) --> <!-- Settings drawer (right) -->
<div <div
id="settings-drawer" id="settings-drawer"
@@ -113,7 +110,6 @@ templ Reader(user User, metadata ReaderMetadata) {
> >
@ReaderSettingsDrawer() @ReaderSettingsDrawer()
</div> </div>
<!-- Selection popover (highlights) --> <!-- Selection popover (highlights) -->
<div <div
id="selection-popover" id="selection-popover"
@@ -139,7 +135,13 @@ templ Reader(user User, metadata ReaderMetadata) {
:style="'background-color:' + c" :style="'background-color:' + c"
:title="selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'" :title="selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'"
:aria-label="'Highlight color ' + c" :aria-label="'Highlight color ' + c"
@click="selectionPopover.mode === 'create' ? createHighlight(c) : (selectionPopover.color = c, saveHighlightChanges())" @click="selectionPopover.noteOpen
? (selectionPopover.mode === 'create'
? (selectionPopover.color = c)
: (selectionPopover.color = c, saveHighlightChanges(true)))
: (selectionPopover.mode === 'create'
? createHighlight(c)
: (selectionPopover.color = c, saveHighlightChanges()))"
></button> ></button>
</template> </template>
<div class="w-px h-5 reader-sep"></div> <div class="w-px h-5 reader-sep"></div>
@@ -188,12 +190,11 @@ templ Reader(user User, metadata ReaderMetadata) {
type="button" type="button"
class="flex-1 py-1 text-xs border rounded hover:opacity-80" class="flex-1 py-1 text-xs border rounded hover:opacity-80"
style="border-color: var(--border);" style="border-color: var(--border);"
@click="selectionPopover.noteOpen = false" @click="hideSelectionPopover()"
>Cancel</button> >Cancel</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Annotations drawer (right): highlights / notes / bookmarks --> <!-- Annotations drawer (right): highlights / notes / bookmarks -->
<div <div
id="annotations-drawer" id="annotations-drawer"
@@ -210,7 +211,6 @@ templ Reader(user User, metadata ReaderMetadata) {
> >
@ReaderAnnotationsDrawer() @ReaderAnnotationsDrawer()
</div> </div>
<!-- Search drawer (right, reflowable only) --> <!-- Search drawer (right, reflowable only) -->
<div <div
id="search-drawer" id="search-drawer"
@@ -227,7 +227,6 @@ templ Reader(user User, metadata ReaderMetadata) {
> >
@ReaderSearchDrawer() @ReaderSearchDrawer()
</div> </div>
<!-- Shortcuts & gestures help modal --> <!-- Shortcuts & gestures help modal -->
<div <div
x-show="helpOpen" x-show="helpOpen"
@@ -263,17 +262,14 @@ templ Reader(user User, metadata ReaderMetadata) {
<div class="help-row"><span>Settings</span><span class="help-keys"><kbd class="kbd">s</kbd></span></div> <div class="help-row"><span>Settings</span><span class="help-keys"><kbd class="kbd">s</kbd></span></div>
<div class="help-row"><span>Add bookmark</span><span class="help-keys"><kbd class="kbd">b</kbd></span></div> <div class="help-row"><span>Add bookmark</span><span class="help-keys"><kbd class="kbd">b</kbd></span></div>
<div class="help-row"><span>Close menus / toggle chrome</span><span class="help-keys"><kbd class="kbd">Esc</kbd></span></div> <div class="help-row"><span>Close menus / toggle chrome</span><span class="help-keys"><kbd class="kbd">Esc</kbd></span></div>
<h4 class="reader-help-section" x-show="isFixedLayout">Zoom &amp; pan <span class="help-note">(comics, PDFs)</span></h4> <h4 class="reader-help-section" x-show="isFixedLayout">Zoom &amp; pan <span class="help-note">(comics, PDFs)</span></h4>
<div class="help-row" x-show="isFixedLayout"><span>Zoom in / out / reset</span><span class="help-keys"><kbd class="kbd">+</kbd><kbd class="kbd">−</kbd><kbd class="kbd">0</kbd> or wheel</span></div> <div class="help-row" x-show="isFixedLayout"><span>Zoom in / out / reset</span><span class="help-keys"><kbd class="kbd">+</kbd><kbd class="kbd">−</kbd><kbd class="kbd">0</kbd> or wheel</span></div>
<div class="help-row" x-show="isFixedLayout"><span>Move the page</span><span class="help-keys">drag with the mouse</span></div> <div class="help-row" x-show="isFixedLayout"><span>Move the page</span><span class="help-keys">drag with the mouse</span></div>
<div class="help-row" x-show="isFixedLayout"><span>Recenter <span class="help-note">(keeps zoom)</span></span><span class="help-keys">⊙ button</span></div> <div class="help-row" x-show="isFixedLayout"><span>Recenter <span class="help-note">(keeps zoom)</span></span><span class="help-keys">⊙ button</span></div>
<div class="help-row" x-show="isPDF"><span>Select text in a PDF</span><span class="help-keys">Smart mode: drag over text · <kbd class="kbd">Shift</kbd>+drag anywhere · Text mode</span></div> <div class="help-row" x-show="isPDF"><span>Select text in a PDF</span><span class="help-keys">Smart mode: drag over text · <kbd class="kbd">Shift</kbd>+drag anywhere · Text mode</span></div>
<h4 class="reader-help-section">Highlights</h4> <h4 class="reader-help-section">Highlights</h4>
<div class="help-row"><span>Create</span><span class="help-keys">select text → pick a color</span></div> <div class="help-row"><span>Create</span><span class="help-keys">select text → pick a color</span></div>
<div class="help-row"><span>Edit / delete</span><span class="help-keys">click the highlight</span></div> <div class="help-row"><span>Edit / delete</span><span class="help-keys">click the highlight</span></div>
<h4 class="reader-help-section">Touch</h4> <h4 class="reader-help-section">Touch</h4>
<div class="help-row"><span>Page turn</span><span class="help-keys">swipe, or tap outer edges</span></div> <div class="help-row"><span>Page turn</span><span class="help-keys">swipe, or tap outer edges</span></div>
<div class="help-row"><span>Toggle chrome</span><span class="help-keys">tap center</span></div> <div class="help-row"><span>Toggle chrome</span><span class="help-keys">tap center</span></div>
@@ -295,7 +291,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
<h1 class="text-base sm:text-lg font-semibold hidden sm:block sm:truncate">{ metadata.Title }</h1> <h1 class="text-base sm:text-lg font-semibold hidden sm:block sm:truncate">{ metadata.Title }</h1>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark this position (b)">🏷️</button> <button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark this position (b)">🏷️</button>
<button x-show="!isFixedLayout || isPDF" @click="toggleSearch()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Search (/)">🔍</button> <button x-show="(!isFixedLayout || isPDF) && !treatAsComic" @click="toggleSearch()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Search (/)">🔍</button>
<button @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Annotations">📝</button> <button @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Annotations">📝</button>
<button @click="toggleSettings()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Settings (s)">Aa</button> <button @click="toggleSettings()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Settings (s)">Aa</button>
<button @click="toggleHelp()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold" title="Shortcuts & gestures (?)" aria-label="Shortcuts and gestures">?</button> <button @click="toggleHelp()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold" title="Shortcuts & gestures (?)" aria-label="Shortcuts and gestures">?</button>
@@ -338,7 +334,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="w-px h-6 reader-sep"></div> <div class="w-px h-6 reader-sep"></div>
<div id="progress-display" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden"> <div id="progress-display" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
<span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain">—</span> <span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain">—</span>
</div> </div>
<div class="w-px h-6 reader-sep"></div> <div class="w-px h-6 reader-sep"></div>
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button> <button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
@@ -348,92 +344,93 @@ templ ReaderChrome(metadata ReaderMetadata) {
<div x-show="isFixedLayout"> <div x-show="isFixedLayout">
<!-- Full toolbar: desktop/tablet (>= 768px, md). Wraps gracefully in between. --> <!-- Full toolbar: desktop/tablet (>= 768px, md). Wraps gracefully in between. -->
<div class="hidden md:flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1"> <div class="hidden md:flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1">
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-0.5">
<button @click="goLeft()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Left (←)" aria-label="Go left"> <button @click="goLeft()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Left (←)" aria-label="Go left">
<svg class="reader-icon" width="24" height="24" aria-hidden="true">
<path d="M 15 6 L 9 12 L 15 18"></path>
</svg>
</button>
<button x-show="backStack.length > 0" @click="goBackToLocation()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Return to previous location (Alt+←)" aria-label="Return to previous location">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z"></path>
<circle cx="12" cy="9.5" r="2"></circle>
</svg>
</button>
</div>
<input
id="progress-slider-fx"
type="range"
min="0"
max="1"
step="any"
@input="goToFraction($event.target.value)"
class="grow min-w-[3rem]"
/>
<button @click="goRight()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right">
<svg class="reader-icon" width="24" height="24" aria-hidden="true"> <svg class="reader-icon" width="24" height="24" aria-hidden="true">
<path d="M 15 6 L 9 12 L 15 18"></path> <path d="M 9 6 L 15 12 L 9 18"></path>
</svg> </svg>
</button> </button>
<button x-show="backStack.length > 0" @click="goBackToLocation()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Return to previous location (Alt+←)" aria-label="Return to previous location"> <div class="w-px h-6 mx-1 reader-sep"></div>
<!-- Fit mode + zoom cluster (not applicable in webtoon flow) -->
<div class="flex items-center gap-1" x-show="comicFlow !== 'webtoon'">
<select
@change="applyFitMode($event.target.value)"
class="reader-select text-xs px-1.5 py-1 rounded-lg shrink-0"
title="Fit mode"
aria-label="Fit mode"
>
<option value="fit-page">Fit Page</option>
<option value="fit-width">Fit Width</option>
</select>
<button @click="zoomOut()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Zoom Out (-)" aria-label="Zoom out">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 5 10 L 15 10"></path>
</svg>
</button>
<button @click="resetZoom()" class="text-xs px-1 rounded-lg hover:bg-gray-700 min-w-[3rem]" title="Reset Zoom (0)" aria-label="Reset zoom">
<span x-text="zoomPercent + '%'">100%</span>
</button>
<button @click="zoomIn()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Zoom In (+)" aria-label="Zoom in">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 10 5 L 10 15 M 5 10 L 15 10"></path>
</svg>
</button>
<!-- Recenter: reset pan, keep zoom -->
<button @click="recenterView()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Recenter page (reset pan, keep zoom)" aria-label="Recenter page">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M 12 2 V 5 M 12 19 V 22 M 2 12 H 5 M 19 12 H 22"></path>
</svg>
</button>
<!-- Magnifier -->
<button @click="toggleMagnifier()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" :class="magnifierEnabled ? 'bg-blue-600 hover:bg-blue-700' : ''" title="Magnifier" aria-label="Toggle magnifier">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<circle cx="9" cy="9" r="5"></circle>
<path d="M 13 13 L 18 18"></path>
</svg>
</button>
</div>
<!-- Pointer mode (PDF only): Smart / Pan / Text — hidden for
comic-treated PDFs: no text selection, pan only -->
<div class="reader-seg shrink-0" x-show="isPDF && !treatAsComic" role="group" aria-label="Pointer mode" title="Smart: drag text to select, drag blank to pan · Pan: drag anywhere to move · Text: selection only. Shift always force-pans.">
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')" title="Smart: drag text to select, drag blank to pan">Smart</button>
<button :class="interactionMode === 'pan' ? 'active' : ''" @click="setInteractionMode('pan')" title="Pan: drag anywhere to move">Pan</button>
<button :class="interactionMode === 'text' ? 'active' : ''" @click="setInteractionMode('text')" title="Text: selection only, never pan (Shift overrides)">Text</button>
</div>
<!-- Double page spread (not applicable in webtoon flow) -->
<button x-show="comicFlow !== 'webtoon'" @click="toggleSpread()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" :class="doublePageSpread ? 'bg-blue-600 hover:bg-blue-700' : ''" title="Double Page Spread" aria-label="Toggle double page spread">
<svg class="reader-icon" width="20" height="20" aria-hidden="true"> <svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z"></path> <path d="M 3 5 h 6 v 14 h -6 z M 15 5 h 6 v 14 h -6 z"></path>
<circle cx="12" cy="9.5" r="2"></circle>
</svg> </svg>
</button> </button>
</div> <!-- Progress + TOC -->
<input <div class="flex items-center gap-1">
id="progress-slider-fx" <div id="progress-display-fx" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[3.5rem] text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
type="range" <span x-text="progressMain">—</span>
min="0" </div>
max="1" <button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
step="any"
@input="goToFraction($event.target.value)"
class="grow min-w-[3rem]"
/>
<button @click="goRight()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right">
<svg class="reader-icon" width="24" height="24" aria-hidden="true">
<path d="M 9 6 L 15 12 L 9 18"></path>
</svg>
</button>
<div class="w-px h-6 mx-1 reader-sep"></div>
<!-- Fit mode + zoom cluster (not applicable in webtoon flow) -->
<div class="flex items-center gap-1" x-show="comicFlow !== 'webtoon'">
<select
@change="applyFitMode($event.target.value)"
class="reader-select text-xs px-1.5 py-1 rounded-lg shrink-0"
title="Fit mode"
aria-label="Fit mode"
>
<option value="fit-page">Fit Page</option>
<option value="fit-width">Fit Width</option>
</select>
<button @click="zoomOut()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Zoom Out (-)" aria-label="Zoom out">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 5 10 L 15 10"></path>
</svg>
</button>
<button @click="resetZoom()" class="text-xs px-1 rounded-lg hover:bg-gray-700 min-w-[3rem]" title="Reset Zoom (0)" aria-label="Reset zoom">
<span x-text="zoomPercent + '%'">100%</span>
</button>
<button @click="zoomIn()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Zoom In (+)" aria-label="Zoom in">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 10 5 L 10 15 M 5 10 L 15 10"></path>
</svg>
</button>
<!-- Recenter: reset pan, keep zoom -->
<button @click="recenterView()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Recenter page (reset pan, keep zoom)" aria-label="Recenter page">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M 12 2 V 5 M 12 19 V 22 M 2 12 H 5 M 19 12 H 22"></path>
</svg>
</button>
<!-- Magnifier -->
<button @click="toggleMagnifier()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" :class="magnifierEnabled ? 'bg-blue-600 hover:bg-blue-700' : ''" title="Magnifier" aria-label="Toggle magnifier">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<circle cx="9" cy="9" r="5"></circle>
<path d="M 13 13 L 18 18"></path>
</svg>
</button>
</div>
<!-- Pointer mode (PDF only): Smart / Pan / Text -->
<div class="reader-seg shrink-0" x-show="isPDF" role="group" aria-label="Pointer mode" title="Smart: drag text to select, drag blank to pan · Pan: drag anywhere to move · Text: selection only. Shift always force-pans.">
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')" title="Smart: drag text to select, drag blank to pan">Smart</button>
<button :class="interactionMode === 'pan' ? 'active' : ''" @click="setInteractionMode('pan')" title="Pan: drag anywhere to move">Pan</button>
<button :class="interactionMode === 'text' ? 'active' : ''" @click="setInteractionMode('text')" title="Text: selection only, never pan (Shift overrides)">Text</button>
</div>
<!-- Double page spread (not applicable in webtoon flow) -->
<button x-show="comicFlow !== 'webtoon'" @click="toggleSpread()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" :class="doublePageSpread ? 'bg-blue-600 hover:bg-blue-700' : ''" title="Double Page Spread" aria-label="Toggle double page spread">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 3 5 h 6 v 14 h -6 z M 15 5 h 6 v 14 h -6 z"></path>
</svg>
</button>
<!-- Progress + TOC -->
<div class="flex items-center gap-1">
<div id="progress-display-fx" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[3.5rem] text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
<span x-text="progressMain">—</span>
</div> </div>
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
</div>
</div> </div>
<!-- Compact row (phone-sized windows): single line + ⋯ overflow. <!-- Compact row (phone-sized windows): single line + ⋯ overflow.
Standard mobile-reader pattern: paging, slider, progress stay; Standard mobile-reader pattern: paging, slider, progress stay;
@@ -528,7 +525,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
<span>Night mode</span> <span>Night mode</span>
<button @click="fxInvert = !fxInvert; applyFxFilter(true)" class="reader-popover-btn text-xs px-2" :class="fxInvert ? 'text-blue-400' : ''" x-text="fxInvert ? 'On' : 'Off'">Off</button> <button @click="fxInvert = !fxInvert; applyFxFilter(true)" class="reader-popover-btn text-xs px-2" :class="fxInvert ? 'text-blue-400' : ''" x-text="fxInvert ? 'On' : 'Off'">Off</button>
</div> </div>
<div class="reader-tools-row" x-show="isPDF"> <div class="reader-tools-row" x-show="isPDF && !treatAsComic">
<span>Pointer</span> <span>Pointer</span>
<div class="reader-seg" role="group" aria-label="Pointer mode"> <div class="reader-seg" role="group" aria-label="Pointer mode">
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')">Smart</button> <button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')">Smart</button>
@@ -700,7 +697,7 @@ templ ReaderAnnotationsDrawer() {
@keydown.enter="renameBookmark(bookmark)" @keydown.enter="renameBookmark(bookmark)"
@keydown.escape="bookmark.renameOpen = false" @keydown.escape="bookmark.renameOpen = false"
placeholder="Bookmark name…" placeholder="Bookmark name…"
></input> />
<div class="flex gap-2 mt-1"> <div class="flex gap-2 mt-1">
<button @click="renameBookmark(bookmark)" class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">Save</button> <button @click="renameBookmark(bookmark)" class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
<button @click="bookmark.renameOpen = false" class="flex-1 py-1 text-xs border rounded hover:opacity-80" style="border-color: var(--border);">Cancel</button> <button @click="bookmark.renameOpen = false" class="flex-1 py-1 text-xs border rounded hover:opacity-80" style="border-color: var(--border);">Cancel</button>
File diff suppressed because one or more lines are too long
+12 -12
View File
@@ -99,7 +99,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
var templ_7745c5c3_Var2 templ.SafeURL var templ_7745c5c3_Var2 templ.SafeURL
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1)) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 50, Col: 93} return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 50, Col: 93}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -125,7 +125,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", currentPage)) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", currentPage))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 58, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 58, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -138,7 +138,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages)) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 58, Col: 82} return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 58, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -156,7 +156,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
var templ_7745c5c3_Var5 templ.SafeURL var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1)) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 62, Col: 93} return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 62, Col: 93}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -224,7 +224,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
var templ_7745c5c3_Var7 templ.SafeURL var templ_7745c5c3_Var7 templ.SafeURL
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(series.Name)) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(series.Name))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 79, Col: 64} return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 79, Col: 64}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -246,7 +246,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String()) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -269,7 +269,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(cover) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(cover)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 84, Col: 17} return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 84, Col: 17}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -282,7 +282,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String()) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -295,7 +295,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(series.Name) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(series.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 86, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 86, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -327,7 +327,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 98, Col: 99} return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 98, Col: 99}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -340,7 +340,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.BookCount)) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.BookCount))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 100, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 100, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -353,7 +353,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.TotalInSeries)) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.TotalInSeries))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 100, Col: 89} return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 100, Col: 89}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+1 -1
View File
@@ -37,7 +37,7 @@ func ErrorToast(message string) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("showErrorToast('" + message + "')") templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("showErrorToast('" + message + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/toast.templ`, Line: 5, Col: 71} return templ.Error{Err: templ_7745c5c3_Err, FileName: `toast.templ`, Line: 5, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+31 -4
View File
@@ -41,22 +41,46 @@ type LibraryData struct {
TypeValue string TypeValue string
MediaCount int64 MediaCount int64
FolderCount int FolderCount int
// Offline is true when none of the library's folders exist on disk
// (e.g. an unmounted external drive). Contents stay intact and reappear
// when the storage returns.
Offline bool
} }
type FolderData struct { type FolderData struct {
FolderPath string FolderPath string
} }
// ArchivedItem feeds the admin archived-items page: media hidden from
// libraries because their files vanished, with their pending fate. When an
// active item with identical content (same SHA-256) exists, MatchID and
// friends are set so the UI can offer merging the archived row's reading
// data into its active twin.
type ArchivedItem struct {
ID string
Title string
Author string
LibraryName string
FilePath string
MissingScans int32
ArchivedAt *time.Time
PurgeAt *time.Time
Status string
MatchID string
MatchTitle string
MatchLibraryName string
}
type DirEntry struct { type DirEntry struct {
Name string Name string
Path string Path string
} }
type UserVisibilityData struct { type UserVisibilityData struct {
UserID string UserID string
Username string Username string
Email string Email string
IsVisible bool IsVisible bool
} }
type AdminStats struct { type AdminStats struct {
@@ -225,6 +249,9 @@ type ReaderMetadata struct {
LibraryID string `json:"library_id"` LibraryID string `json:"library_id"`
TotalCharacters int64 `json:"total_characters"` TotalCharacters int64 `json:"total_characters"`
EstimatedPages int `json:"estimated_pages"` EstimatedPages int `json:"estimated_pages"`
// TreatAsComic: fixed-layout item in a comics/manga library gets
// comic reader treatment (bookmarks only — no highlights, no search).
TreatAsComic bool `json:"treat_as_comic"`
} }
type ReadingProgress struct { type ReadingProgress struct {
+10 -10
View File
@@ -105,7 +105,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 63, Col: 100} return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 63, Col: 100}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -118,7 +118,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 63, Col: 136} return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 63, Col: 136}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -151,7 +151,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 77, Col: 111} return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 77, Col: 111}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -164,7 +164,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 79, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 79, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -177,7 +177,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 79, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 79, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -194,7 +194,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 88, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 88, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -212,7 +212,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 94, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 94, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -225,7 +225,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 94, Col: 84} return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 94, Col: 84}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -243,7 +243,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 99, Col: 71} return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 99, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -261,7 +261,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 105, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `unlinked_books.templ`, Line: 105, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+6
View File
@@ -65,6 +65,12 @@ func isUserVisible(userID string, visibility []UserVisibilityData) bool {
func TotalMediaCount(libs []LibraryData) int64 { func TotalMediaCount(libs []LibraryData) int64 {
var total int64 var total int64
for _, l := range libs { for _, l := range libs {
// Unmounted storage contributes nothing visible: an offline
// library's holdings stay in the database but out of the UI
// until its folders return.
if l.Offline {
continue
}
total += l.MediaCount total += l.MediaCount
} }
return total return total
+139
View File
@@ -249,6 +249,145 @@ function stopScanStatusPolling(): void {
} }
} }
// Purge all archived items (files missing from disk for 2+ scans). Exposed on
// window for the library admin page's inline button; reading history is
// deleted with the rows, so a confirm dialog guards it.
async function purgeArchivedItems(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
if (
!window.confirm(
"Permanently delete all archived items? Their reading progress, notes, and highlights will be lost.",
)
) {
return;
}
try {
const resp = await fetch("/api/media-items/purge-archived", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to purge archived items");
}
const data = await resp.json();
showToast(
`Purged ${data.purged} archived item${data.purged === 1 ? "" : "s"}`,
"success",
);
setTimeout(() => window.location.reload(), 700);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to purge archived items",
"error",
);
}
}
(window as any).purgeArchivedItems = purgeArchivedItems;
// Restore a hidden/archived item to library visibility without waiting for
// its file to return (POST /api/media-items/:id/unarchive). If the file is
// still gone the next scan hides it again.
async function unarchiveItem(id: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const resp = await fetch(`/api/media-items/${id}/unarchive`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to restore item");
}
showToast("Item restored to libraries", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(e instanceof Error ? e.message : "Failed to restore item", "error");
}
}
// Permanently delete one archived item and its reading history
// (DELETE /api/media-items/:id).
async function deleteArchivedItem(id: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
if (
!window.confirm(
"Permanently delete this item? Its reading progress, notes, and highlights will be lost.",
)
) {
return;
}
try {
const resp = await fetch(`/api/media-items/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to delete item");
}
showToast("Item deleted", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(e instanceof Error ? e.message : "Failed to delete item", "error");
}
}
// Merge an archived item's reading data into its active duplicate with the
// same content hash (POST /api/media-items/:id/merge), then remove the
// archived entry. Covers copy-then-delete-later moves between libraries.
async function mergeArchivedItem(sourceId: string, targetId: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
if (
!window.confirm(
"Merge this archived copy's reading progress, notes, and highlights into the active copy? The archived entry is removed.",
)
) {
return;
}
try {
const resp = await fetch(`/api/media-items/${sourceId}/merge`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ target_id: targetId }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to merge item");
}
showToast("Reading data merged into active copy", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(e instanceof Error ? e.message : "Failed to merge item", "error");
}
}
// The archived-items page renders per-row buttons with data attributes
// (escaping-safe); bind them here. Module scripts run after DOM parse.
document.querySelectorAll<HTMLElement>("[data-unarchive]").forEach((el) => {
el.addEventListener("click", () => unarchiveItem(el.dataset.unarchive || ""));
});
document.querySelectorAll<HTMLElement>("[data-delete-archived]").forEach((el) => {
el.addEventListener("click", () =>
deleteArchivedItem(el.dataset.deleteArchived || ""),
);
});
document.querySelectorAll<HTMLElement>("[data-merge-source]").forEach((el) => {
el.addEventListener("click", () =>
mergeArchivedItem(el.dataset.mergeSource || "", el.dataset.mergeTarget || ""),
);
});
export { export {
hideScanProgress, hideScanProgress,
loadWatchStatus, loadWatchStatus,
+38 -37
View File
@@ -1,6 +1,5 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
import { generateCoverBlob } from "./cover-generator";
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown"; import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
function getMediaId(): string { function getMediaId(): string {
@@ -91,13 +90,13 @@ export {
interface MetadataEditorState { interface MetadataEditorState {
openSections: Record<string, boolean>; openSections: Record<string, boolean>;
coverGenerating: boolean;
hasExistingCover: boolean; hasExistingCover: boolean;
newCoverPreview: string; newCoverPreview: string;
coverFile: Blob | null; coverFile: Blob | null;
coverAction: string; coverAction: string;
saving: boolean; saving: boolean;
rescanning: boolean; rescanning: boolean;
resetting: boolean;
userRating: number; userRating: number;
ratingHover: number; ratingHover: number;
ratingSaving: boolean; ratingSaving: boolean;
@@ -108,10 +107,10 @@ interface MetadataEditorState {
showMetadataEditor(): void; showMetadataEditor(): void;
hideMetadataEditor(): void; hideMetadataEditor(): void;
handleCoverUpload(event: Event): void; handleCoverUpload(event: Event): void;
generateCover(): Promise<void>;
removeCover(): void; removeCover(): void;
saveMetadata(): Promise<void>; saveMetadata(): Promise<void>;
rescanBook(): Promise<void>; rescanBook(): Promise<void>;
resetMetadata(): Promise<void>;
starFill(i: number): string; starFill(i: number): string;
ratingText(): string; ratingText(): string;
setRating(value: number): Promise<void>; setRating(value: number): Promise<void>;
@@ -130,12 +129,6 @@ Alpine.data("bookDetail", () => {
coverImg?.src && coverImg?.src &&
!coverImg.src.includes("placeholder-book.svg"); !coverImg.src.includes("placeholder-book.svg");
const coverPreviewEl = document.querySelector(
".aspect-\\[2\\/3\\] img",
) as HTMLImageElement | null;
const coverSrc = coverPreviewEl?.src || "";
const fileUrl = coverSrc && !coverSrc.includes("placeholder") ? coverSrc : "";
const initialTags: string[] = []; const initialTags: string[] = [];
const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]"); const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]");
tagBadges.forEach((el) => { tagBadges.forEach((el) => {
@@ -145,13 +138,13 @@ Alpine.data("bookDetail", () => {
return { return {
openSections: { basic: true } as Record<string, boolean>, openSections: { basic: true } as Record<string, boolean>,
coverGenerating: false,
hasExistingCover: !!hasCover, hasExistingCover: !!hasCover,
newCoverPreview: "", newCoverPreview: "",
coverFile: null as Blob | null, coverFile: null as Blob | null,
coverAction: "keep", coverAction: "keep",
saving: false, saving: false,
rescanning: false, rescanning: false,
resetting: false,
userRating: 0, userRating: 0,
ratingHover: 0, ratingHover: 0,
ratingSaving: false, ratingSaving: false,
@@ -462,33 +455,6 @@ Alpine.data("bookDetail", () => {
reader.readAsDataURL(file); reader.readAsDataURL(file);
}, },
async generateCover() {
this.coverGenerating = true;
try {
const formatGroup =
document
.querySelector('[data-format-group]')
?.getAttribute("data-format-group") || "reflowable";
const blob = await generateCoverBlob(fileUrl, formatGroup);
if (!blob) return;
this.coverFile = blob;
this.coverAction = "upload";
const preview = document.getElementById(
"metadata-cover-preview",
) as HTMLImageElement;
if (preview) {
preview.src = URL.createObjectURL(blob);
}
this.newCoverPreview = URL.createObjectURL(blob);
showToast("Cover generated successfully", "success");
} finally {
this.coverGenerating = false;
}
},
removeCover() { removeCover() {
this.coverAction = "remove"; this.coverAction = "remove";
this.coverFile = null; this.coverFile = null;
@@ -587,6 +553,41 @@ Alpine.data("bookDetail", () => {
} }
}, },
async resetMetadata() {
if (this.resetting) return;
if (
!window.confirm(
"Reset all metadata to scanned defaults? Manual edits and custom covers will be discarded.",
)
) {
return;
}
this.resetting = true;
const mediaId = getMediaId();
try {
const resp = await fetch(
`/api/media-items/${mediaId}/rescan?reset_overrides=true`,
{
method: "POST",
headers: { Authorization: getAuthHeader() },
},
);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to reset metadata");
}
showToast("Metadata reset to scanned defaults", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to reset metadata",
"error",
);
} finally {
this.resetting = false;
}
},
async searchEditorTags() { async searchEditorTags() {
const libraryId = document.body.getAttribute("data-library-id") || ""; const libraryId = document.body.getAttribute("data-library-id") || "";
if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) { if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) {
-82
View File
@@ -1,82 +0,0 @@
import { showToast } from "./toast";
function getToken(): string {
return localStorage.getItem("token") || "";
}
export async function generateCoverBlob(
fileUrl: string,
formatGroup: string,
): Promise<Blob | null> {
try {
const View = (await import("foliate-js/view.js")).default;
const view = new View();
const resp = await fetch(fileUrl, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!resp.ok) {
showToast("Failed to fetch book file for cover generation", "error");
return null;
}
const blob = await resp.blob();
const file = new File([blob], "book", { type: blob.type });
const pdfOptions =
formatGroup === "fixed_layout"
? {
pdf: {
cMapUrl: "/static/vendor/pdfjs/cmaps/",
standardFontDataUrl: "/static/vendor/pdfjs/standard_fonts/",
},
}
: {};
await view.open(file, pdfOptions);
if (!view.book?.sections?.length) {
showToast("Could not read book sections", "error");
return null;
}
if (formatGroup === "fixed_layout") {
const canvas = document.createElement("canvas");
await view.renderer?.renderPage(view.book.sections[0], canvas);
return new Promise((resolve) => {
canvas.toBlob(
(b) => resolve(b),
"image/jpeg",
0.85,
);
});
}
const coverHref = view.book.cover;
if (coverHref) {
const coverBlob = await coverHref.blob();
if (coverBlob.type.startsWith("image/")) {
return coverBlob;
}
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const c = document.createElement("canvas");
c.width = img.naturalWidth;
c.height = img.naturalHeight;
c.getContext("2d")?.drawImage(img, 0, 0);
c.toBlob((b) => resolve(b), "image/jpeg", 0.85);
};
img.onerror = () => resolve(null);
img.src = URL.createObjectURL(coverBlob);
});
}
showToast("No cover found in book file", "error");
return null;
} catch (e) {
console.error("Cover generation failed:", e);
showToast("Cover generation failed", "error");
return null;
}
}
+601 -130
View File
@@ -358,6 +358,15 @@ const getCSS = ({
text-align: ${justify ? "justify" : "start"} !important; text-align: ${justify ? "justify" : "start"} !important;
hyphens: ${hyphenate ? "auto" : "none"}; hyphens: ${hyphenate ? "auto" : "none"};
} }
/* Chrome's touch word-selection walks hyphen fragments: dragging from a
line-start word re-anchors at the line-end fragment (silently dropping
the selected word) and fast drags overshoot into the next column.
Coarse pointers get whole words only. */
@media (pointer: coarse) {
p, li, blockquote, dd {
hyphens: ${hyphenate ? "manual" : "none"};
}
}
[align="left"] { text-align: left; } [align="left"] { text-align: left; }
[align="right"] { text-align: right; } [align="right"] { text-align: right; }
[align="center"] { text-align: center; } [align="center"] { text-align: center; }
@@ -389,6 +398,10 @@ document.addEventListener("alpine:init", () => {
isFixedLayout: false, isFixedLayout: false,
isPDF: false, isPDF: false,
isComic: false, isComic: false,
// Fixed-layout item in a comics/manga library (e.g. a manga PDF):
// comic reader treatment — bookmarks only, no text-selection
// highlights, no in-book search.
treatAsComic: false as boolean,
comicFlow: "paged" as string, comicFlow: "paged" as string,
fxBrightness: 1 as number, fxBrightness: 1 as number,
fxContrast: 1 as number, fxContrast: 1 as number,
@@ -404,12 +417,20 @@ document.addEventListener("alpine:init", () => {
pointerFine: true as boolean, pointerFine: true as boolean,
fxZoomed: false as boolean, fxZoomed: false as boolean,
tapZonesEnabled: true as boolean, tapZonesEnabled: true as boolean,
tapZoneSize: 30 as number, tapZoneSize: 12 as number,
tapZoneTimer: null as ReturnType<typeof setTimeout> | null, tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
// Any live text selection, host document or content iframe. Fed by // Any live text selection, host document or content iframe. Fed by
// selectionchange listeners (touch devices); tap zones stand down // selectionchange listeners (touch devices); tap zones stand down
// while one exists. // while one exists.
anySelection: false as boolean, anySelection: false as boolean,
// The section document most recently loaded (for clearing its
// selection after actions that consume it).
currentDoc: null as any,
// Timestamp of the last touch press (pointer:coarse surfaces); a
// selection appearing during a short press is Chrome's tap-to-select,
// not a selection gesture.
lastTouchDownT: 0 as number,
selPopTimer: null as ReturnType<typeof setTimeout> | null,
highlightItems: [] as { highlightItems: [] as {
id: string; id: string;
text: string; text: string;
@@ -571,8 +592,10 @@ document.addEventListener("alpine:init", () => {
formatGroup: string; formatGroup: string;
readingDirection: string; readingDirection: string;
mangaType: string; mangaType: string;
treatAsComic: boolean;
}) { }) {
this.mediaItemId = config.mediaItemId; this.mediaItemId = config.mediaItemId;
this.treatAsComic = config.treatAsComic;
// Reading state (position, bookmarks, annotations) is never baked // Reading state (position, bookmarks, annotations) is never baked
// into the rendered page: the web reader is intrinsically tied to // into the rendered page: the web reader is intrinsically tied to
// the server, so it reads all of it from the APIs at open time — // the server, so it reads all of it from the APIs at open time —
@@ -598,7 +621,7 @@ document.addEventListener("alpine:init", () => {
this.chromeBehavior = this.chromeBehavior =
behavior === "always-visible" ? "always-visible" : "auto-hide"; behavior === "always-visible" ? "always-visible" : "auto-hide";
this.tapZonesEnabled = this.settings.tap_zones_enabled ?? true; this.tapZonesEnabled = this.settings.tap_zones_enabled ?? true;
this.tapZoneSize = this.settings.tap_zone_size || 30; this.tapZoneSize = this.settings.tap_zone_size || 12;
const mode = this.settings.pdf_interaction_mode; const mode = this.settings.pdf_interaction_mode;
if (mode === "pan" || mode === "text" || mode === "select") { if (mode === "pan" || mode === "text" || mode === "select") {
this.interactionMode = mode; this.interactionMode = mode;
@@ -679,6 +702,7 @@ document.addEventListener("alpine:init", () => {
} }
this.view.addEventListener("load", (e: any) => { this.view.addEventListener("load", (e: any) => {
const { doc, index } = e.detail; const { doc, index } = e.detail;
this.currentDoc = doc;
const link = doc.createElement("link"); const link = doc.createElement("link");
link.rel = "stylesheet"; link.rel = "stylesheet";
link.href = "/static/reader-fonts.css"; link.href = "/static/reader-fonts.css";
@@ -702,64 +726,61 @@ document.addEventListener("alpine:init", () => {
this.noteSelectionActivity( this.noteSelectionActivity(
!!sel && !sel.isCollapsed && !!sel.toString(), !!sel && !sel.isCollapsed && !!sel.toString(),
); );
// The gesture takeover swallows pointerup for touch selection
// drags, so the popover's pointerup trigger never fires for
// them; a selection that has been stable for a moment is the
// settled gesture — open the popover for it.
if (
this.isFixedLayout &&
!this.treatAsComic &&
doc.querySelector(".textLayer")
)
this.schedulePDFSelectionPopover(doc, index);
else if (!this.isFixedLayout)
this.scheduleSelectionPopover(doc, index);
}); });
} }
// Clicks in the book dismiss the popover in ANY mode and for ANY
// content doc (EPUB sections and fixed-layout pages alike): iframe
// events never bubble to the host document (so the host
// outside-click dismiss never sees them). Clicking a painted
// highlight still works: this hides, then foliate's
// show-annotation re-opens it in edit mode.
doc.addEventListener(
"pointerdown",
() => {
if (this.selectionPopover.open)
this.hideSelectionPopover();
},
{ passive: true },
);
// Text selection → highlight popover (reflowable EPUB only; // Text selection → highlight popover (reflowable EPUB only;
// fixed-layout highlight overlays are a later milestone). // fixed-layout highlight overlays are a later milestone).
if (!this.isFixedLayout) { if (!this.isFixedLayout) {
const checkSelection = () => { const checkSelection = (e?: PointerEvent) => {
const sel = doc.getSelection(); const sel = doc.getSelection();
// Chrome on touch selects the word under a quick tap — that is
// a page-turn tap, not a selection gesture; clear it and show
// nothing. Real hold selections never reach this pointerup
// (the gesture takeover swallows it) — they open the popover
// through the selectionchange debounce below.
if (
e && e.pointerType === "touch" && this.lastTouchDownT &&
Date.now() - this.lastTouchDownT < 300
) {
sel?.removeAllRanges();
return;
}
if (!sel || sel.isCollapsed || !sel.rangeCount) { if (!sel || sel.isCollapsed || !sel.rangeCount) {
if (this.selectionPopover.mode === "create") if (this.selectionPopover.mode === "create")
this.hideSelectionPopover(); this.hideSelectionPopover();
return; return;
} }
const range = sel.getRangeAt(0); this.openPopoverForSelection(sel, doc, index);
const text = sel.toString().replace(/\s+/g, " ").trim();
if (!text) return;
let cfi: string;
let cfiEnd: string;
try {
cfi = this.view.getCFI(index, range);
// Collapse to the end point for a distinct end anchor —
// KOReader sync renders the highlight box from pos0/pos1, and
// pos1 == pos0 would be a degenerate (zero-length) range.
const endRange = range.cloneRange();
endRange.collapse(false);
cfiEnd = this.view.getCFI(index, endRange);
} catch {
return;
}
const frame = doc.defaultView?.frameElement as HTMLElement | null;
const iframeRect = frame?.getBoundingClientRect();
const rect = range.getBoundingClientRect();
this.openSelectionPopover({
mode: "create",
x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2,
y: (iframeRect?.top ?? 0) + rect.top,
text,
cfi,
cfiEnd,
});
}; };
doc.addEventListener( doc.addEventListener(
"pointerup", "pointerup",
() => setTimeout(checkSelection, 0), (e: PointerEvent) => setTimeout(() => checkSelection(e), 0),
{ passive: true },
);
// Clicks in the book dismiss the popover in ANY mode: iframe
// events never bubble to the host document (so the host
// outside-click dismiss never sees them), and the collapsed
// check above only covers create mode — edit mode had no
// outside-click path at all, leaving Esc as the only way out.
// Clicking a painted highlight still works: this hides, then
// foliate's show-annotation re-opens it in edit mode.
doc.addEventListener(
"pointerdown",
() => {
if (this.selectionPopover.open)
this.hideSelectionPopover();
},
{ passive: true }, { passive: true },
); );
doc.addEventListener( doc.addEventListener(
@@ -774,66 +795,29 @@ document.addEventListener("alpine:init", () => {
// Detected structurally: renderer.isPDF isn't set until the first // Detected structurally: renderer.isPDF isn't set until the first
// spread renders (during view.init), which is after this listener // spread renders (during view.init), which is after this listener
// attaches — the stale copy here would always be falsy. // attaches — the stale copy here would always be falsy.
if (this.isFixedLayout && doc.querySelector(".textLayer")) { // Comic-treated PDFs never attach it: no highlights, like comics.
const checkPDFSelection = () => { if (
const sel = doc.getSelection(); this.isFixedLayout &&
if (!sel || sel.isCollapsed || !sel.rangeCount) return; !this.treatAsComic &&
const range = sel.getRangeAt(0); doc.querySelector(".textLayer")
const text = sel.toString().replace(/\s+/g, " ").trim(); ) {
if (!text) return;
// Denominator: the element whose post-transform screen rect IS
// the visible page. For PDFs that's the rendered canvas — pdf.js
// scales <html> by 1/devicePixelRatio, so documentElement's rect
// is dpr× too small and would inflate every fraction (highlight
// shifted right/oversized on any dpr != 1 display). The canvas's
// rect is in the same transform-inclusive space as the textLayer
// span rects, so the dpr scaling cancels exactly.
const denom =
(doc.querySelector("#canvas canvas") as HTMLElement) ||
(doc.querySelector("img") as HTMLElement);
let dr = denom?.getBoundingClientRect();
if (!dr || !dr.width || !dr.height) {
const vw = doc.defaultView;
dr = {
left: 0,
top: 0,
width: vw?.innerWidth || 1,
height: vw?.innerHeight || 1,
} as DOMRect;
}
const rects: number[][] = [];
for (const r of range.getClientRects()) {
const x = (r.left - dr.left) / dr.width;
const y = (r.top - dr.top) / dr.height;
const w = r.width / dr.width;
const h = r.height / dr.height;
if (w > 0 && h > 0) rects.push([x, y, w, h]);
}
if (!rects.length) return;
// Map the first rect to host-space for popover placement.
// The canvas's screen rect maps 1:1 onto the host iframe box
// (the visible page fills the iframe), so sx/sy are 1 for PDFs;
// kept general for the comic img fallback.
const frame = doc.defaultView?.frameElement as HTMLElement | null;
if (!frame) return;
const fr = frame.getBoundingClientRect();
const first = range.getBoundingClientRect();
const sx = fr.width / dr.width;
const sy = fr.height / dr.height;
this.pdfSelDoc = doc;
this.openSelectionPopover({
mode: "create",
x: fr.left + first.left * sx + (first.width * sx) / 2,
y: fr.top + first.top * sy,
text,
cfi: "",
pdfPage: index,
pdfRects: rects,
});
};
doc.addEventListener( doc.addEventListener(
"pointerup", "pointerup",
() => setTimeout(checkPDFSelection, 0), (e: PointerEvent) =>
setTimeout(() => {
// Chrome on touch selects the word under a quick tap —
// that is a page-turn tap, not a selection gesture; clear
// it and show nothing (same guard as the reflowable path).
if (
e.pointerType === "touch" &&
this.lastTouchDownT &&
Date.now() - this.lastTouchDownT < 300
) {
doc.getSelection()?.removeAllRanges();
return;
}
this.checkPDFSelectionFor(doc, index);
}, 0),
{ passive: true }, { passive: true },
); );
} }
@@ -885,7 +869,18 @@ document.addEventListener("alpine:init", () => {
this.view.addEventListener("relocate", (e: any) => { this.view.addEventListener("relocate", (e: any) => {
const { fraction, location, pageItem, cfi, tocItem, section } = const { fraction, location, pageItem, cfi, tocItem, section } =
e.detail; e.detail;
this.hideSelectionPopover(); // Only dismiss the popover on an actual position change — the
// paginator also emits no-op relocates (e.g. a tap's page-aligned
// anchor), which would otherwise close a just-opened highlight
// edit popover. NOTE: detail.section is an object (recreated on
// every relocate), so it can't be compared by reference; the
// fraction threshold alone covers section changes (any section
// boundary moves the whole-book fraction far more than 0.1%).
const prev = this.lastRelocateDetail;
const moved =
!prev ||
Math.abs((prev.fraction ?? 0) - (fraction ?? 0)) > 0.001;
if (moved) this.hideSelectionPopover();
this.lastCfi = cfi || ""; this.lastCfi = cfi || "";
this.lastRelocateDetail = { this.lastRelocateDetail = {
fraction, fraction,
@@ -959,6 +954,9 @@ document.addEventListener("alpine:init", () => {
// Smart|Pan|Text control appear for PDFs. // Smart|Pan|Text control appear for PDFs.
this.isPDF = !!this.renderer?.isPDF; this.isPDF = !!this.renderer?.isPDF;
if (this.isPDF) { if (this.isPDF) {
// Comic-treated PDFs are paged through like comics: no text
// selection, so pointer interactions stay in pan mode.
if (this.treatAsComic) this.interactionMode = "pan";
this.renderer.setAttribute("interaction-mode", this.interactionMode); this.renderer.setAttribute("interaction-mode", this.interactionMode);
} }
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null; this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
@@ -1006,6 +1004,10 @@ document.addEventListener("alpine:init", () => {
this.pokeChrome(); this.pokeChrome();
}, },
pokeChrome() { pokeChrome() {
// Never raise the chrome while a touch selection is live — the
// selection popover owns the screen until the selection is
// consumed or dismissed.
if (this.anySelection) return;
this.chromeVisible = true; this.chromeVisible = true;
if (this.chromeBehavior !== "auto-hide") return; if (this.chromeBehavior !== "auto-hide") return;
if (this.hideTimer) clearTimeout(this.hideTimer); if (this.hideTimer) clearTimeout(this.hideTimer);
@@ -1057,6 +1059,15 @@ document.addEventListener("alpine:init", () => {
clearTimeout(this.tapZoneTimer); clearTimeout(this.tapZoneTimer);
this.tapZoneTimer = null; this.tapZoneTimer = null;
} }
// Hide the chrome the moment a selection appears — it competes
// with the selection popover and handles for screen space.
if (hasSelection && this.chromeVisible) {
this.chromeVisible = false;
if (this.hideTimer) {
clearTimeout(this.hideTimer);
this.hideTimer = null;
}
}
}, },
attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) { attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) {
let downX = 0; let downX = 0;
@@ -1080,6 +1091,7 @@ document.addEventListener("alpine:init", () => {
downId = e.pointerId; downId = e.pointerId;
moved = false; moved = false;
longPressed = false; longPressed = false;
this.lastTouchDownT = downT;
}, },
{ passive: true }, { passive: true },
); );
@@ -1129,16 +1141,26 @@ document.addEventListener("alpine:init", () => {
// No tap actions while a fixed-layout page is zoomed — taps then // No tap actions while a fixed-layout page is zoomed — taps then
// belong to the content (and double-tap zoom). // belong to the content (and double-tap zoom).
if (this.isFixedLayout && this.renderer?.zoom != null) return; if (this.isFixedLayout && this.renderer?.zoom != null) return;
const width = // Content iframes are laid out at the full section width (the
(isDoc // paginator shows one column slice of a much wider document), so
? (surface as any).documentElement.clientWidth // clientX must be mapped into the visible page before it can be
: (surface as HTMLElement).getBoundingClientRect().width) || 1; // compared against the tap zones — dividing by the iframe's
const relX = // clientWidth puts every tap in the left zone.
(e.clientX - let relX: number;
(isDoc if (isDoc) {
? 0 const r: any = this.renderer;
: (surface as HTMLElement).getBoundingClientRect().left)) / const docWidth = (surface as any).documentElement.clientWidth || 1;
width; if (r && !r.scrolled && r.size) {
const base = r.start - r.size;
relX = (e.clientX - base) / r.size;
} else {
relX = e.clientX / docWidth;
}
if (this.view?.book?.dir === "rtl") relX = 1 - relX;
} else {
const rect = (surface as HTMLElement).getBoundingClientRect();
relX = (e.clientX - rect.left) / (rect.width || 1);
}
this.routeTapDebounced(relX); this.routeTapDebounced(relX);
}, },
{ passive: true }, { passive: true },
@@ -1154,6 +1176,13 @@ document.addEventListener("alpine:init", () => {
} }
this.tapZoneTimer = setTimeout(() => { this.tapZoneTimer = setTimeout(() => {
this.tapZoneTimer = null; this.tapZoneTimer = null;
// Touch pages by swipe only (the paginator's pan + snap); a tap
// anywhere just toggles the chrome. The edge zones remain for
// mouse-driven taps.
if (window.matchMedia("(pointer: coarse)").matches) {
this.toggleChromeManually();
return;
}
const zone = this.tapZoneSize / 100; const zone = this.tapZoneSize / 100;
if (relX < zone) this.goLeft(); if (relX < zone) this.goLeft();
else if (relX > 1 - zone) this.goRight(); else if (relX > 1 - zone) this.goRight();
@@ -1199,10 +1228,14 @@ document.addEventListener("alpine:init", () => {
p.noteOpen = !!p.note && opts.mode === "edit"; p.noteOpen = !!p.note && opts.mode === "edit";
p.pdfPage = opts.pdfPage ?? -1; p.pdfPage = opts.pdfPage ?? -1;
p.pdfRects = opts.pdfRects ?? []; p.pdfRects = opts.pdfRects ?? [];
// Clamp so the popover stays on screen (it anchors bottom-center). // Clamp so the popover stays on screen (it anchors bottom-center;
// the popover is ~240px wide, so the fixed margins must cover half
// of it). NOTE: do not mutate the position after opening — touching
// the style mid-enter-transition leaves the popover invisible to
// compositor hit-testing (taps fall through to the content iframe).
const w = window.innerWidth; const w = window.innerWidth;
const h = window.innerHeight; const h = window.innerHeight;
p.x = Math.min(Math.max(opts.x, 90), w - 90); p.x = Math.min(Math.max(opts.x, 130), w - 130);
p.y = Math.min(Math.max(opts.y, 60), h - 60); p.y = Math.min(Math.max(opts.y, 60), h - 60);
p.open = true; p.open = true;
}, },
@@ -1210,6 +1243,266 @@ document.addEventListener("alpine:init", () => {
this.selectionPopover.open = false; this.selectionPopover.open = false;
this.selectionPopover.noteOpen = false; this.selectionPopover.noteOpen = false;
}, },
scheduleSelectionPopover(doc: any, index: number) {
if (this.selPopTimer) clearTimeout(this.selPopTimer);
this.selPopTimer = setTimeout(() => {
this.selPopTimer = null;
// A tap on a painted highlight re-opens the popover in edit mode
// via its own path — never clobber it.
if (this.selectionPopover.open && this.selectionPopover.mode === "edit")
return;
const sel = doc.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) {
if (
this.selectionPopover.open &&
this.selectionPopover.mode === "create"
)
this.hideSelectionPopover();
this.hideSelectionHandles();
return;
}
// The browser's selection auto-scroll (drags near a viewport
// edge) scrolls the paginated container off the page grid, and
// a selection that crosses the page edge keeps its reveal-scroll
// chasing the off-page text — the view bounces between pages
// forever. Correct the view to the anchor's page and clamp the
// selection to the visible page; scrollBy clamps to the current
// section, so the correction can never cross sections.
const r: any = this.renderer;
if (r && !r.scrolled && r.size && this.view?.book?.dir !== "rtl") {
try {
const anchorProbe = doc.createRange();
anchorProbe.setStart(sel.anchorNode, sel.anchorOffset);
// collapsed ranges report degenerate (0,0) rects — extend by
// one character to get a measurable one
if (
sel.anchorNode?.nodeType === 3 &&
sel.anchorOffset < sel.anchorNode.length
)
anchorProbe.setEnd(sel.anchorNode, sel.anchorOffset + 1);
const rects = anchorProbe.getClientRects();
const ar =
rects[rects.length - 1] || anchorProbe.getBoundingClientRect();
// rect x is already in the iframe's own (content) space,
// i.e. scroll-invariant: page = floor(x / column stride)
const desired = (Math.floor(ar.x / r.size) + 1) * r.size;
const needsScroll = Math.abs(r.start - desired) > r.size * 0.02;
if (needsScroll) r.scrollBy(desired - r.start, 0);
const clamped = this.clampSelectionToPage(sel, doc, r);
if (clamped)
// the clamp's selectionchange re-arms this settle; the
// next pass finds view and selection in place and opens
// the popover
return;
if (needsScroll) {
setTimeout(() => {
const s = doc.getSelection();
if (s && s.rangeCount && !s.isCollapsed && s.type === "Range")
this.openPopoverForSelection(s, doc, index);
}, 350);
return;
}
} catch {
/* anchor geometry unavailable */
}
}
this.openPopoverForSelection(sel, doc, index);
}, 400);
},
// PDF mirror of scheduleSelectionPopover: the gesture takeover
// swallows pointerup for touch selection drags in the fx iframe too,
// so a selection that has been stable for a moment is the settled
// gesture — open the popover for it.
schedulePDFSelectionPopover(doc: any, index: number) {
if (this.selPopTimer) clearTimeout(this.selPopTimer);
this.selPopTimer = setTimeout(() => {
this.selPopTimer = null;
// A tap on a painted highlight re-opens the popover in edit mode
// via its own path — never clobber it.
if (this.selectionPopover.open && this.selectionPopover.mode === "edit")
return;
const sel = doc.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) {
if (
this.selectionPopover.open &&
this.selectionPopover.mode === "create"
)
this.hideSelectionPopover();
return;
}
this.checkPDFSelectionFor(doc, index);
}, 400);
},
checkPDFSelectionFor(doc: any, index: number) {
const sel = doc.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
const text = sel.toString().replace(/\s+/g, " ").trim();
if (!text) return;
// Denominator: the element whose post-transform screen rect IS
// the visible page. For PDFs that's the rendered canvas — pdf.js
// scales <html> by 1/devicePixelRatio, so documentElement's rect
// is dpr× too small and would inflate every fraction (highlight
// shifted right/oversized on any dpr != 1 display). The canvas's
// rect is in the same transform-inclusive space as the textLayer
// span rects, so the dpr scaling cancels exactly.
const denom =
(doc.querySelector("#canvas canvas") as HTMLElement) ||
(doc.querySelector("img") as HTMLElement);
let dr = denom?.getBoundingClientRect();
if (!dr || !dr.width || !dr.height) {
const vw = doc.defaultView;
dr = {
left: 0,
top: 0,
width: vw?.innerWidth || 1,
height: vw?.innerHeight || 1,
} as DOMRect;
}
const rects: number[][] = [];
for (const r of range.getClientRects()) {
const x = (r.left - dr.left) / dr.width;
const y = (r.top - dr.top) / dr.height;
const w = r.width / dr.width;
const h = r.height / dr.height;
if (w > 0 && h > 0) rects.push([x, y, w, h]);
}
if (!rects.length) return;
// Map the first rect to host-space for popover placement.
// The canvas's screen rect maps 1:1 onto the host iframe box
// (the visible page fills the iframe), so sx/sy are 1 for PDFs;
// kept general for the comic img fallback.
const frame = doc.defaultView?.frameElement as HTMLElement | null;
if (!frame) return;
const fr = frame.getBoundingClientRect();
const first = range.getBoundingClientRect();
const sx = fr.width / dr.width;
const sy = fr.height / dr.height;
// On touch the browser's own selection menu attaches ABOVE the
// selection and its drag handles sit just below it — place ours
// below both (same +90 clearance the reflowable path uses). Fine
// pointers (desktop) keep the above-the-selection placement.
const coarse = window.matchMedia("(pointer: coarse)").matches;
const y = coarse
? fr.top + first.bottom * sy + 90
: fr.top + first.top * sy;
this.pdfSelDoc = doc;
this.openSelectionPopover({
mode: "create",
x: fr.left + first.left * sx + (first.width * sx) / 2,
y,
text,
cfi: "",
pdfPage: index,
pdfRects: rects,
});
},
// Trim a touch selection to the visible page. The visible column
// occupies content x [start - size, start]; endpoints beyond it are
// re-mapped to the page edge on their own line via caretRangeFromPoint.
clampSelectionToPage(sel: any, doc: any, r: any): boolean {
const left = r.start - r.size;
const right = r.start;
const TOL = 8;
const backwardProbe = doc.createRange();
backwardProbe.setStart(sel.anchorNode, sel.anchorOffset);
backwardProbe.setEnd(sel.focusNode, sel.focusOffset);
const backward = backwardProbe.collapsed;
const range = sel.getRangeAt(0);
const probeRect = (node: any, offset: number, forward: boolean) => {
const p = doc.createRange();
p.setStart(node, offset);
if (node?.nodeType === 3) {
if (forward && offset < node.length)
p.setEnd(node, offset + 1);
else if (!forward && offset > 0)
p.setStart(node, offset - 1);
}
if (p.collapsed) return null;
const rects = p.getClientRects();
return rects.length ? rects[forward ? 0 : rects.length - 1] : null;
};
const caretAt = (x: number, y: number) => {
const caret = doc.caretRangeFromPoint?.(x, y)
?? doc.caretPositionFromPoint?.(x, y);
if (!caret) return null;
return {
node: caret.startContainer ?? caret.offsetNode,
offset: caret.startOffset ?? caret.offset,
};
};
// selection end beyond the visible page (into the next column):
// trim to the last position on the visible page (bottom-right
// corner of the column — nearest-text mapping is unambiguous
// from the margin)
const endRect = probeRect(range.endContainer, range.endOffset, false);
if (endRect && endRect.x + endRect.width > right + TOL) {
const caret = caretAt(
right - 15, doc.documentElement.clientHeight - 40);
if (caret && range.isPointInRange(caret.node, caret.offset)) {
if (backward)
sel.setBaseAndExtent(caret.node, caret.offset,
sel.focusNode, sel.focusOffset);
else sel.extend(caret.node, caret.offset);
return true;
}
}
// selection start before the visible page (previous column):
// trim to the first position on the visible page (top-left corner)
const startRect = probeRect(range.startContainer, range.startOffset, true);
if (startRect && startRect.x < left - TOL) {
const caret = caretAt(left + 15, 40);
if (caret && range.isPointInRange(caret.node, caret.offset)) {
if (backward)
sel.extend(caret.node, caret.offset);
else
sel.setBaseAndExtent(caret.node, caret.offset,
sel.focusNode, sel.focusOffset);
return true;
}
}
return false;
},
openPopoverForSelection(sel: any, doc: any, index: number) {
const range = sel.getRangeAt(0);
const text = sel.toString().replace(/\s+/g, " ").trim();
if (!text) return;
let cfi: string;
let cfiEnd: string;
try {
cfi = this.view.getCFI(index, range);
// Collapse to the end point for a distinct end anchor —
// KOReader sync renders the highlight box from pos0/pos1, and
// pos1 == pos0 would be a degenerate (zero-length) range.
const endRange = range.cloneRange();
endRange.collapse(false);
cfiEnd = this.view.getCFI(index, endRange);
} catch {
return;
}
const frame = doc.defaultView?.frameElement as HTMLElement | null;
const iframeRect = frame?.getBoundingClientRect();
const rect = range.getBoundingClientRect();
// On touch the browser's own selection menu attaches ABOVE the
// selection (and paints over page content) — place ours BELOW it
// so both are visible. The popover anchors bottom-center at y-10
// and is ~44px tall; the drag handles occupy the first 24px below
// the selection, so the offset must clear them too.
const coarse = window.matchMedia("(pointer: coarse)").matches;
const y = coarse
? (iframeRect?.top ?? 0) + rect.bottom + 90
: (iframeRect?.top ?? 0) + rect.top;
this.openSelectionPopover({
mode: "create",
x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2,
y,
text,
cfi,
cfiEnd,
});
if (coarse && this.view?.book?.dir !== "rtl")
this.showSelectionHandles(doc);
},
renderAllHighlights() { renderAllHighlights() {
for (const hl of this.highlightItems) { for (const hl of this.highlightItems) {
if (hl.pdfPage >= 0) { if (hl.pdfPage >= 0) {
@@ -1332,7 +1625,12 @@ document.addEventListener("alpine:init", () => {
const rows = await hlResp.json(); const rows = await hlResp.json();
this.highlightItems = (rows as any[]) this.highlightItems = (rows as any[])
.map((r) => this.mapHighlightRow(r)) .map((r) => this.mapHighlightRow(r))
.filter((hl: any) => hl.cfi || hl.pdfPage >= 0); // Comic-treated items show no highlights (bookmarks only),
// and previously-created PDF highlights stop rendering.
.filter(
(hl: any) =>
!this.treatAsComic && (hl.cfi || hl.pdfPage >= 0),
);
this.renderAllHighlights(); this.renderAllHighlights();
} }
if (noteResp.ok) { if (noteResp.ok) {
@@ -1351,6 +1649,9 @@ document.addEventListener("alpine:init", () => {
const p = this.selectionPopover; const p = this.selectionPopover;
const token = getToken(); const token = getToken();
if (!token || !this.mediaItemId || (!p.cfi && p.pdfPage < 0)) return; if (!token || !this.mediaItemId || (!p.cfi && p.pdfPage < 0)) return;
// The note editor writes into p.note live; "Highlight with note"
// creates the highlight AND its note in one POST.
const note = p.note.trim();
const pdfAnchor = const pdfAnchor =
p.pdfPage >= 0 p.pdfPage >= 0
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects }) ? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
@@ -1371,7 +1672,7 @@ document.addEventListener("alpine:init", () => {
epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi, epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd, epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd,
color, color,
note_text: "", note_text: note,
percentage_start: this.lastRelocateDetail?.fraction ?? 0, percentage_start: this.lastRelocateDetail?.fraction ?? 0,
}), }),
}, },
@@ -1400,16 +1701,23 @@ document.addEventListener("alpine:init", () => {
? "" ? ""
: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi, : this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
color, color,
note: "", note,
id: row.id, id: row.id,
}); });
// Clear the DOM selection: the gesture is consumed, and it is
// what keeps Chrome's native copy/share menu on screen.
try {
this.currentDoc?.getSelection()?.removeAllRanges();
} catch {
/* doc may be gone */
}
} }
this.hideSelectionPopover(); this.hideSelectionPopover();
} catch (_e) { } catch (_e) {
/* ignore highlight errors */ /* ignore highlight errors */
} }
}, },
async saveHighlightChanges() { async saveHighlightChanges(keepNoteOpen = false) {
const p = this.selectionPopover; const p = this.selectionPopover;
const token = getToken(); const token = getToken();
if (!token || !this.mediaItemId || !p.id) return; if (!token || !this.mediaItemId || !p.id) return;
@@ -1473,7 +1781,9 @@ document.addEventListener("alpine:init", () => {
id: p.id, id: p.id,
}); });
} }
p.noteOpen = false; // Explicit saves (the Save button) close the whole popover; color
// tweaks made while the note editor is open keep everything open.
if (!keepNoteOpen) this.hideSelectionPopover();
} catch (_e) { } catch (_e) {
/* ignore highlight errors */ /* ignore highlight errors */
} }
@@ -1504,12 +1814,170 @@ document.addEventListener("alpine:init", () => {
} }
}, },
async copySelectionText() { async copySelectionText() {
const text = this.selectionPopover.text;
try { try {
await navigator.clipboard.writeText(this.selectionPopover.text); if (navigator.clipboard?.writeText) {
this.hideSelectionPopover(); await navigator.clipboard.writeText(text);
} catch (_e) { this.finishCopy();
/* clipboard unavailable */ return;
}
} catch {
/* fall through to the legacy path */
} }
// Plain HTTP (typical LAN access) has no navigator.clipboard;
// a transient textarea + execCommand works from this user
// gesture on insecure contexts.
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.append(ta);
ta.select();
document.execCommand("copy");
ta.remove();
this.finishCopy();
} catch {
/* ignore clipboard errors */
}
},
finishCopy() {
try {
this.currentDoc?.getSelection()?.removeAllRanges();
} catch {
/* doc may be gone */
}
this.hideSelectionPopover();
},
// ----- touch selection handles -----
// The native selection handles cannot be used (the custom gesture's
// touchstart preventDefault disables them along with the rest of the
// native controller), so adjustment after lift happens through these.
// Purely imperative DOM — position updates run on every drag frame.
selectionHandles: {
root: null as HTMLElement | null,
start: null as HTMLElement | null,
end: null as HTMLElement | null,
drag: null as "start" | "end" | null,
doc: null as any,
},
ensureSelectionHandles() {
const h = this.selectionHandles as any;
if (h.root) return;
const root = document.createElement("div");
root.id = "selection-handles";
const make = (which: "start" | "end") => {
const el = document.createElement("div");
el.className = "sel-handle";
el.addEventListener("pointerdown", (e: PointerEvent) => {
e.preventDefault();
e.stopPropagation();
h.drag = which;
el.setPointerCapture(e.pointerId);
});
el.addEventListener("pointermove", (e: PointerEvent) => {
if (h.drag !== which) return;
e.preventDefault();
this.dragSelectionHandle(e);
});
const release = () => {
h.drag = null;
};
el.addEventListener("pointerup", release);
el.addEventListener("pointercancel", release);
root.append(el);
return el;
};
h.start = make("start");
h.end = make("end");
h.root = root;
document.body.append(root);
},
showSelectionHandles(doc: any) {
this.ensureSelectionHandles();
const h = this.selectionHandles as any;
h.doc = doc;
h.root!.style.display = "block";
this.positionSelectionHandles(doc);
},
hideSelectionHandles() {
const h = this.selectionHandles as any;
if (!h.root) return;
h.root.style.display = "none";
h.drag = null;
},
// Host-space position of a selection boundary: the left edge of the
// start caret or the right edge of the end caret, at the line bottom.
caretHostPos(doc: any, node: any, offset: number, forward: boolean) {
try {
const p = doc.createRange();
p.setStart(node, offset);
if (node?.nodeType === 3) {
if (forward && offset < node.length) p.setEnd(node, offset + 1);
else if (!forward && offset > 0) p.setStart(node, offset - 1);
}
if (p.collapsed) return null;
const rects = p.getClientRects();
const r = rects[forward ? 0 : rects.length - 1];
if (!r) return null;
const off = doc.defaultView?.frameElement?.getBoundingClientRect();
return {
x: (off?.left ?? 0) + (forward ? r.left : r.right),
y: (off?.top ?? 0) + r.bottom,
};
} catch {
return null;
}
},
positionSelectionHandles(doc: any) {
const h = this.selectionHandles as any;
if (!h.root || h.root.style.display === "none") return;
const sel = doc.getSelection();
if (!sel?.rangeCount || sel.isCollapsed) {
this.hideSelectionHandles();
return;
}
const range = sel.getRangeAt(0);
const startPos = this.caretHostPos(doc, range.startContainer,
range.startOffset, true);
const endPos = this.caretHostPos(doc, range.endContainer,
range.endOffset, false);
if (!startPos || !endPos) {
this.hideSelectionHandles();
return;
}
h.start!.style.transform = `translate(${startPos.x - 6}px, ${startPos.y}px)`;
h.end!.style.transform = `translate(${endPos.x - 6}px, ${endPos.y}px)`;
},
dragSelectionHandle(ev: PointerEvent) {
const h = this.selectionHandles as any;
const doc = h.doc;
const r: any = this.renderer;
if (!doc || !r || r.scrolled) return;
if (this.view?.book?.dir === "rtl") return;
const frame = doc.defaultView?.frameElement;
if (!frame) return;
const off = frame.getBoundingClientRect();
// pointer → iframe content space, clamped to the visible page
const size = r.size;
const left = r.start - size;
const right = r.start;
const x = Math.min(Math.max(ev.clientX - off.left, left + 2), right - 2);
const y = ev.clientY - off.top;
const caret = doc.caretRangeFromPoint?.(x, y)
?? doc.caretPositionFromPoint?.(x, y);
if (!caret) return;
const node = caret.startContainer ?? caret.offsetNode;
const offset = caret.startOffset ?? caret.offset;
const sel = doc.getSelection();
if (!sel?.rangeCount || !node) return;
const range = sel.getRangeAt(0);
if (h.drag === "start")
sel.setBaseAndExtent(node, offset, range.endContainer, range.endOffset);
else
sel.setBaseAndExtent(range.startContainer, range.startOffset,
node, offset);
this.positionSelectionHandles(doc);
}, },
goToHighlight(hl: { goToHighlight(hl: {
cfi: string; cfi: string;
@@ -1698,6 +2166,8 @@ document.addEventListener("alpine:init", () => {
}, },
setInteractionMode(mode: string) { setInteractionMode(mode: string) {
if (!this.isFixedLayout || !this.isPDF) return; if (!this.isFixedLayout || !this.isPDF) return;
// Comic-treated PDFs have no text selection: pan only.
if (this.treatAsComic) return;
if (mode !== "select" && mode !== "pan" && mode !== "text") return; if (mode !== "select" && mode !== "pan" && mode !== "text") return;
this.renderer.setAttribute("interaction-mode", mode); this.renderer.setAttribute("interaction-mode", mode);
this.interactionMode = mode; this.interactionMode = mode;
@@ -1792,8 +2262,9 @@ document.addEventListener("alpine:init", () => {
}, },
toggleSearch() { toggleSearch() {
// Reflowable books use foliate's DOM search; PDFs use extracted text. // Reflowable books use foliate's DOM search; PDFs use extracted text.
// Comics have no text at all. // Comics have no text at all — and comic-treated PDFs (manga scans)
if (this.isFixedLayout && !this.isPDF) return; // opt out of search to match comic treatment: bookmarks only.
if ((this.isFixedLayout && !this.isPDF) || this.treatAsComic) return;
const opening = !this.searchOpen; const opening = !this.searchOpen;
this.closeDrawers(); this.closeDrawers();
this.searchOpen = opening; this.searchOpen = opening;
@@ -2201,8 +2672,8 @@ document.addEventListener("alpine:init", () => {
this.hyphenate = true; this.hyphenate = true;
this.chromeBehavior = "auto-hide"; this.chromeBehavior = "auto-hide";
this.applyChromeBehavior(); this.applyChromeBehavior();
this.interactionMode = "select"; this.interactionMode = this.treatAsComic ? "pan" : "select";
this.setInteractionMode("select"); this.setInteractionMode(this.interactionMode);
this.fxBrightness = 1; this.fxBrightness = 1;
this.fxContrast = 1; this.fxContrast = 1;
this.fxInvert = false; this.fxInvert = false;
+1 -1
View File
File diff suppressed because one or more lines are too long
+31
View File
@@ -1434,3 +1434,34 @@
} }
} }
} }
/* Touch selection handles (custom gesture — the native ones are disabled) */
#selection-handles {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 55;
}
#selection-handles .sel-handle {
position: absolute;
top: 0;
left: 0;
width: 12px;
height: 24px;
pointer-events: auto;
touch-action: none;
background: #8ab4f8;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
opacity: 0.95;
}
#selection-handles .sel-handle::before {
content: "";
position: absolute;
top: -4px;
left: 50%;
width: 2px;
height: 5px;
background: inherit;
transform: translateX(-50%);
}
+1 -1
View File
File diff suppressed because one or more lines are too long