Compare commits

..
21 Commits
Author SHA1 Message Date
John O'Keefe 2165829d0e fix(ux): confirm modal's Cancel/Confirm were dead on the promise-API path
Release / build-and-push (push) Successful in 3m29s
User report: the Purge Archived dialog ('Permanently delete all
archived items?...') opened, and Cancel did nothing — and Confirm
would have been equally dead, leaving the page stuck behind the
overlay until a reload.

Root cause: confirm.ts wired the modal's buttons ONLY inside the
htmx:confirm interceptor. confirmDialog() — the promise API used by
purgeArchivedItems, remove-account, and other TS call sites — never
wired them. So whenever a page's first confirm interaction came from
the promise path (no hx-confirm element clicked first), both buttons
had no listeners. The 2026-10-30 confirm_probe spec only exercised the
hx-confirm path, which is why it survived.

Fix: wireButtons() now runs at module init (the header renders before
deferred modules) and inside confirmDialog() — both idempotent via the
existing data-wired guard.

Pinned by tests/ui/promise_confirm_probe.spec.ts (no env gating): the
purge POST is route-intercepted, so the probe asserts Cancel closes
the modal and Confirm issues the guarded request without deleting any
dev data. hx-confirm path re-pinned green (confirm_probe with BH_FEED).
Verified manually at 390x844: open -> cancelWired=true -> Cancel
closes -> Confirm fires the intercepted request.
2026-11-02 11:24:55 -05:00
John O'Keefe 04e855e691 fix(ui): right slide-out sheet for oversized modals on phones; 360px fixes
Auditing at 360x760 (Firefox's smallest emulated device; Galaxy S25
class) — 390 was too generous a baseline — the Customize Dashboard
pop-up was the worst page in the app: its card measured 986px in a
760px viewport with no internal scroll, so Cancel/Save sat ~90px below
the fold on a fixed, non-scrolling overlay — unreachable.

New reusable shell: .app-modal-panel (input.css) renders a centered
dialog on desktop (>=640px, unchanged) and a full-height right
slide-out sheet on phones: h-dvh, rounded-l-2xl, slide-in animation
(replays on every open because the overlay toggles display), internal
scroll, overscroll containment. Overlay pairs with items-stretch /
justify-end below sm.

Converted the measured overflows:
- Customize Dashboard (986px): sheet + sticky header/footer, rows wrap
  with the Restore/toggle group right-aligned (ml-auto), titles
  truncate, System badge shrink-0.
- Collection create/edit (796px, cut at both ends at 360): sheet +
  sticky header/footer; edit modal shared the file.

Also at 360: admin-library's Archived buttons row (View Archived +
Purge Archived Now, 315px at x=49) inflated the page to 364px — now
flex-wrap. Collection-episodes shows a 1px sub-pixel rounding artifact
(vw=361) — left as-is, invisible.

Verified at 360x760: sheet geometry (footer bottom 739 < 760), rows
fit, desktop 1280 screenshot unchanged; full-route sweep at 360 shows
no page-level horizontal overflow beyond the two notes above.
2026-11-02 10:55:23 -05:00
John O'Keefe 3e8ac3b9b7 docs(user): koreader install is a git clone (git pull to update), per owner
Replaced the 'clone then copy to plugins/' phrasing with the actual
workflow: clone straight into the plugins folder (koreader/plugins,
.adds/koreader/plugins on Kobo), restart KOReader; updates are git pull
in bookhoard.koplugin + restart. Closes the last prose-trusted item in
the user-docs rebuild.
2026-11-02 10:42:49 -05:00
John O'Keefe 10a5ac88da docs(user): themes — drop the invented 'default light theme' tag, per owner
Lupine's '(default light theme)' tag had no basis anywhere: the string
exists only in the ThemeType union, and there is no light-default logic
in theme.ts or the Appearance panel. Platform-owner clarification:
Tokyo Night is the sole default (fresh install); the Dark/Light control
is a list filter with per-half counts, not a theme toggle. The page now
says exactly that, keeps the code-verified bits (mode filter opens on
your current theme's half via ThemeModeOf, checkmark indicator, counts)
and states the one-default rule plainly.
2026-11-02 10:39:09 -05:00
John O'Keefe 3feb608d26 docs(user): two more code-grounding fixes from the verification sweep
- sync-guide matching ladder was missing rung 8: Title only at 0.3
  confidence, last-resort after title+author+size (book_matching.go
  priorities 1-8, file-path priority handled at the API layer).
- library-browsing said the sort dropdown had 'more' options than it
  does: exactly five (Title A-Z/Z-A, Author A-Z, Date Added, Page
  Count).

Sweep results, all verified against source: themes.md 24+13=37 exact
(web/src/theme.ts union), reader-guide claims (corner zones, selection
copy, comics annotations disabled - bookmarks only) present in
web/src/reader/reader.ts, android-app.md strings (local-network
permission, login error table) in bookhoard-app source, calibre
dc:title/calibre:series parsing in media_scanner_calibre fixtures.
2026-11-02 10:28:45 -05:00
John O'Keefe 9b2d3da7e2 docs(user): koreader-setup — document the actual plugin menu from source
The guide documented setup and stopped. Everything after 'approved' was
undocumented: the real menu tree from bookhoard.koplugin main.lua is now
in the guide — one-tap actions (Push progress / Pull progress / Sync
rating, review & time now / Setup OPDS catalog / Device info /
Disconnect / Register device), Automatically push progress (DEFAULTS
OFF — the likely cause of 'sync not working after approval' reports),
Periodically sync every # pages (default 50), Sync mode
(Immediate/Checkpoint), Sync behavior (Sync to a newer state default
Prompt / to an older state default Never — the device-side side of
conflict handling), and the seven What to sync toggles (progress,
bookmarks, highlights, notes, ratings, review, reading time — all on by
default; the ratings/review/reading-time sync was entirely missing from
docs despite the six server endpoints).

Also fixed: Android 'coming later' line now points at the real app,
OPDS auto-registration verified in the approval poll (setupOPDS on
approved), expiry wording, and a troubleshooting entry for the
wifi-toggle permission message auto-sync can raise. Ground truth noted
in a source comment; verify against plugin source on plugin changes.
2026-11-02 10:23:52 -05:00
John O'Keefe 788e9463cf chore(web): delete dead linking module
The module could never execute against any real page:

- renderUnlinkedBooks targeted #unlinked-books-list, an id that exists
  in no template; the live /unlinked-books page is server-rendered by
  templates.UnlinkedBooks and driven by the Alpine unlinkedBooks
  component (web/src/unlinked_books.ts, setupEventDelegation) with its
  own CSP-safe wiring.
- showSuggestionsModal/hideMatchModal need #match-modal and
  #match-modal-content, which no template renders either.
- Its inline onclick handlers referenced window.showMatchModal, a
  function that was never defined anywhere.

Nothing imports it except main.ts's side-effect import, and no
template/Go/TS surface references its Alpine component registration,
containers, or globals (grepped templates/, internal/, web/src/).
Verified after removal: tsc, vp check, vitest (198), and /unlinked-books
plus dashboard/stats load with zero console errors, empty state and
Alpine component intact.
2026-11-02 10:23:43 -05:00
John O'Keefe ef4de7896a fix(csb): custom-section book picker search never worked — two latent bugs
Fine-toothed functional verification of the delegation work exposed that
the picker's book search has been dead since launch, for two
independent reasons:

- The builder fetched GET /api/books/search, a route no router has ever
  registered — every search 404'd (the CSP-dead onclick buttons on the
  results hid the failure). It now calls the unified JSON search the
  bookshelf uses (/api/media-items/search?q=&library_id=), maps its
  rows (id/title/author/cover_image_path) to the picker's BookInfo
  shape, and treats the endpoint's 404-with-results:[] as empty.

- Even with results, the picker container shared id=search-results with
  the header search dropdown (header.templ renders it on every page);
  getElementById returns the first match in tree order, so results were
  written into the invisible topbar dropdown. The picker container is
  now cs-search-results (templ + all three TS references).

Verified end-to-end at 390x844: select library -> search '1984' -> 3
results render -> click adds the selection chip -> chip x removes it.
Zero console errors.
2026-11-02 10:14:35 -05:00
John O'Keefe 12aab156f6 docs(user): sync-guide rebuild to today's platform; calibre guide de-API'd
sync-guide.md was 591 lines with good implementation-derived bones
(matching confidence table, universal locations, conflict flow) wrapped
around a stale device story: Android 'coming later' (the app exists and
self-registers), a deleted queue UI, invented UI (color status badges,
Auto-Resolve Future, Priority Sync buttons), placeholder support links,
an HTTPS-1.3 overclaim on LAN setups that are plain HTTP by design, and
a changelog promising the future. Rebuilt to ~140 lines, every claim
grounded: device table matches reality (web/Android/KOReader supported,
Kobo native server-complete with rollout pending), resolution
strategies as the API actually defines them (device / server /
highest_progress), offline queuing honestly scoped (Android queues;
server holds per-device queue; no dedicated queue UI today), reading
history on Progress page, RLS-era privacy wording, device limit 10
default admin-settable. Reader Sync Progress button verified.

calibre-integration.md: mostly current (sidecar-first model, Dublin
Core tables, Write-to-file section all kept); fixed the user-hostile
'POST /api/scanner/scan' instructions to point at the admin scan
controls, and dropped the yourusername placeholder repo links.
2026-11-02 10:12:41 -05:00
John O'Keefe c1a7648076 docs(user): reader audio section + kobo guide refresh to platform reality
reader-guide.md gains the audiobook/episode player section (seek,
prev/next episode, Bookmarks panel, Chapters list, Player settings with
autoplay/speed/resume-rewind overrides, Shortcuts dialog) — the player
existed with zero user docs.

kobo-setup.md drops the stale 'not available yet, actively being
developed' framing: the server side of native Kobo sync is implemented
and documented (sync/initialization endpoints, bookmark + markup sync,
shelf mappings, KEPUB sidecar config), so the guide now states exactly
that, keeps stock-firmware rollout as the honest remaining piece, and
keeps KOReader as the supported today-path.

ui-controls.md: Player settings / Shortcuts & interactions / Chapters /
Bookmarks entries (templ-entity fragments for ampersand labels).
2026-11-02 10:09:18 -05:00
John O'Keefe 9e2f216af5 docs(user): dashboard, browsing, profile rewrites + new podcasts guide; trash stubs
Ground-truthed against the templates this pass:
- dashboard.md: real section kinds (system shelves incl. Continue
  Series, user collections, custom sections via /custom-section
  builder), Customize Dashboard panel semantics (per-library, drag,
  switches, 10-50 items, Save Changes), Refresh, This Week widget,
  per-item Read/Listen and progress-conflict resolve actions. The old
  page listed a wrong shelf set and never mentioned custom sections.
- library-browsing.md: the file was secretly titled 'Saving Custom
  Filters' and covered nothing else. Now the browsing page: All Books
  toolbar (search, Sort, Filters -> drawer hand-off, Save/Load/Clear
  saved filters), book detail as hub, series pages, podcasts split.
- profile-guide.md: field table ground-truthed from profile_form.templ
  (username/email DO remain editable there), audio defaults (speed,
  resume rewind, keep awake), timezone rendering contract, both theme
  doors (7-pick select vs 37-theme Appearance panel), Change Password
  with live checks, Danger Zone/Remove My Account + last-admin rule.
- podcasts.md NEW: the Podcasts page had zero documentation. Subscribing
  (Add Feed -> RSS URL, library, back-catalog download, download order,
  Add & Subscribe), Continue/All Feeds, Download All, player + Play as
  Sequence cross-link.
- Trashed user-areas.md, frontend-guide.md, settings-guide.md: stubs
  whose planned content now exists as real pages (or duplicates
  profile-guide). user-guide.md rewritten as the plain-language front
  door with a go-here-when table; index.md rows updated to match.
- ui-controls.md: 10 new gate-checked entries for every newly cited
  control (Add & Subscribe via templ-entity fragment).
2026-11-02 10:07:28 -05:00
John O'Keefe ceed3ddeae fix(stats): Most Active card wrapped mid-token on narrow screens
'2026-11-01 (148 min)' broke as '(148 / min)' in the half-width stat
card. Date and minutes now render as stacked block lines — date,
minutes, weekday — so nothing splits across rows.
2026-11-02 09:46:58 -05:00
John O'Keefe 0673550d58 fix(admin): library rows collapse on phones — chips hid the name
Visual review of the first flex-wrap attempt: with the name block at
flex-1 (0 basis), the row never registered as overfull, so wrap never
fired and the shrink-0 chips still overprinted Scan/Manage. Dropping
flex-1 lets wrap trigger — but then the chips starved the name down to
'P…'. The type/folders chips are the least-important content in the
row: hidden below sm, so phones show icon + full name/description with
Scan/Manage on a second row; desktop keeps the single-line layout with
chips (>=640px unchanged).
2026-11-02 09:46:58 -05:00
John O'Keefe 71b171bf72 fix(csp): allow blob: stylesheets in style-src for the reader
Every /readers load logged a CSP violation: foliate-js injects its
reader-generated stylesheets as blob: URLs, and style-src only allowed
'self' 'unsafe-inline' — so the sheet was refused and selection/
highlight styling inside the reader rode on nothing.

blob: was already granted for img-src, font-src, and frame-src for
exactly this reader content; style-src now matches. The content is
app-generated at runtime (no cross-origin input), script-src is
untouched, and the strict script hash allow-list keeps its guarantee.

Pinned by TestThemeFlashGuardHashMatchesCSP (script-src only — the
style-src addition doesn't interact) and TestGoSourcesAreCSPSafe.
2026-11-02 09:31:58 -05:00
John O'Keefe 2df6f8e55c fix(ui): replace CSP-blocked inline handlers with delegated listeners
The hash-allow-list CSP refuses inline onclick=/onerror= attributes, so
three TS islands shipped dead controls:

- stats.ts rendered Most-Read/Longest covers with onerror= — every
  /stats load logged violations (5+ on the dev data) and the broken
  cover fallback never ran. Now: capture-phase error listeners on the
  three list containers (they persist across innerHTML swaps; error
  does not bubble but capture still reaches them).
- custom-section-builder.ts picker rows and selection chips used
  onclick= against window globals. Rows now carry data-book-id/title/
  author and chips data-remove-book; initCustomSectionBuilder wires one
  delegated click listener per container. window.addBookToSelection/
  removeBookFromSelection exports removed — no remaining references.
- linking.ts: the unlinked-books 'Link' button called
  window.showMatchModal, which never existed anywhere (doubly dead:
  missing global AND CSP-blocked); suggestion rows and Cancel used
  onclick= too. All now data-attribute driven through one document-level
  delegated listener; row Link opens the suggestions flow via
  getSuggestions(progressId), which fetches matches and opens the modal.

Verified: tsc --noEmit, vp check, vitest (198), manual modal flows.
2026-11-02 09:31:58 -05:00
John O'Keefe 88adf30aa9 fix(ui): mobile layout overflows across admin, reader, and book pages
Every fix is a minimal class-level change; desktop layouts are untouched
because each fixed row already fit on one line there.

- progress: CFI string is one unbreakable token — it inflated the page
  layout width to 398-501px (whole-page zoom-out on phones) and rendered
  debug-looking text edge to edge. min-w-0 + break-all lets it wrap.
- audio reader: settings strip min-content (~470px) exceeded the 390px
  viewport, clipping Skip/Sleep and — because the page ICB inflated —
  pushing the chapters drawer (w-full of the viewport) past the screen
  edge so its timestamps clipped too. flex-wrap fixes both symptoms.
- book detail edit-metadata modal footer: Rescan/Reset/Write-to-file row
  overflowed the modal; clipped buttons were unreachable. flex-wrap.
- bookshelf save-filter modal footer: three buttons overflowed both
  screen edges. flex-wrap.
- admin libraries: rows overprinted (badge chips shrink-0 vs actions
  shrink-0, no wrap) — 'manga⟳Scan▸1 folders⌄Manage'. flex-wrap stacks
  actions under the name on phones.
- admin podcasts: maintenance table forced a 892px page (44% zoom).
  overflow-x-auto on the card scrolls the table inside it instead.
- stats: 4-pill range row forced 421px; page permanently ~7% zoomed out.
  flex-wrap.
- podcast feed detail: label+select pairs now travel in their own flex
  pairs so flex-wrap can never split 'Order' from its select; ids and
  htmx change-triggers preserved.
- cosmetics: conflicts stat labels tracking-wide→normal (UNRESOLVED
  clipped), archived file path line-clamp-3, rules-page Smart badge
  wraps cleanly, dashboard View All shrink-0 nowrap (was wrapping to
  two lines).
2026-11-02 09:30:55 -05:00
John O'Keefe e3ca3426ba fix(auth): emit default_audio_speed unconditionally on the profile response
TestDocsMatchRoutes failed on users/get_profile.md: the example carries
default_audio_speed but the response struct tagged it omitempty, so the
gate's zero-value marshal never sees the key and reads the doc as
inventing it. The tag was semantically dead: the column is NOT NULL
DEFAULT 1.0 (schema.sql 44) and writes validate 0.25-4, so a stored 0
is out of domain and every real profile emits the field. Response-only
struct, no form-bind impact. The key was added to the doc example in
ef827a3a without a gate run - this is exactly the class of drift the
gate exists to catch; make test now passes again.
2026-11-02 08:56:37 -05:00
John O'Keefe 42b283480a docs: archive the screenshot-automation plan as rejected
The Playwright screenshot pipeline is not being built: screenshots are
the only doc format no gate can verify, they rot silently at every UI
change, and a redesign was the stated plan. Replaced by
docs/developer/writing-ui-docs.md (pattern-first text, exact quoted
labels enforced by TestUIControlLabels) — see that page for the full
reasoning. Body kept below unmodified for history.
2026-11-02 08:22:01 -05:00
John O'Keefe 913174cdf8 docs(gates): widen relative-link checker to all docs, repair the rot it finds
TestDocsRelativeLinks walked only docs/developer; widening it to all of
docs/ immediately surfaced the same class of rot the gate was built
for, outside its old reach:

- docs/index.md was the old monolith's landing page: linked the deleted
  api-reference.md, 10 directory-style area links, and escaped the docs
  tree to README/AGENTS/.env/compose. Rewritten as a portal whose every
  link resolves, with one representative endpoint doc per API area and
  pointers to the new writing-ui-docs.md standard.
- directory-style links in user stubs (devices/, ../operations/)
- case-mismatched links (Development.md) in contributing.md
- monolith-era pointer (API_REFERENCE.md, TROUBLESHOOTING.md) and
  escaped AGENTS.md links in contributing/development.md
- docs/development/calibre-opf-implementation.md double-up escapes, a
  link to the removed repo-root plan file (absorbed here), and a
  source-code hyperlink the docs site can't serve (now inline code)
- operations/operations.md linked three never-written guide pages as
  aspirational links - they stay plain text until pages exist

HANDS-OFF (project owner, 2026-11-02): docs/user/filters/** and
docs/user/searching-and-filtering.md are exempt as link sources so no
future checker failure can force edits to the filter documentation.
Files linking INTO them remain checked.
2026-11-02 08:21:47 -05:00
John O'Keefe bf4735f3ce docs(user): rewrite collections to the screenshot-free standard
The old page was stale in ways the standard now forbids: spatial
language ("top-right"), documented URL query params users should never
need, no mention of smart collections or podcast episodes, no hand-off
to the filter docs.

Ground-truthed against the templates: Static vs lightning-Smart type
choice, Books/Podcast episodes rules scope, Add Items picker ( Books /
Episodes tabs, Add Selected, Remove Selected), Edit Rules -> Filter
tree -> Apply to Collection, Play as Sequence with the listening-order
picker (Feed order / reversed / oldest / newest), Hide finished books
(reading-queue mode) and Show Up Next strip toggles, system shelves
(Continue Reading, Recently Added, Recently Read, Not Started, Continue
Series) and Restore System -> Restore.

Plain language per the user-docs tone contract: short sentences,
pattern-first (one shelf concept, four flavors), every step ends in an
outcome, rules taught by LINK to the filter guide (reference, never
restate). Every quoted control exists as a literal and is glossaried in
ui-controls.md (gate-checked).
2026-11-02 08:18:27 -05:00
John O'Keefe e07301f74a docs(standard): screenshot-free UI documentation standard + UI-label gate
The planned Playwright screenshot docs (SCREENSHOT_AUTOMATION.md) are
replaced by a text standard: screenshots rot silently and cannot be
gate-checked, while this UI's labels are server-rendered templ literals
that can. Layers: pattern-first prose (concepts survive redesigns) >
capability inventory > label->outcome flows > quoted labels > generated
references where a registry exists (filters/fields.md precedent). The
filter docs stay hands-off exemplars; user-doc tone is plain language by
contract, dev/admin tone explicitly deferred.

writing-ui-docs.md is the standard (page template, findability notes,
tone-by-audience, process rule: a UI change is not done until the docs
audit runs). ui-controls.md is the canonical control-label glossary the
docs quote from; TestUIControlLabels greps every entry against
templates/*.templ (source: ts override; also:-fragments for
Alpine-composed labels like the Match/Group header) so renames fail
make test in the same commit as the rename. Gate proven both ways: a
planted bogus label failed; restored set passes.
2026-11-02 08:18:19 -05:00
64 changed files with 1604 additions and 1396 deletions

No files matched your search

+9 -1
View File
@@ -1,6 +1,14 @@
# Screenshot Automation Plan for Bookhoard Documentation # Screenshot Automation Plan for Bookhoard Documentation
> **Status**: Ready to implement when frontend is complete > **Status**: REJECTED / ARCHIVED (2026-11-02) — not implementing.
> **Superseded by**: [docs/developer/writing-ui-docs.md](docs/developer/writing-ui-docs.md) —
> Bookhoard docs are written screenshot-free from now on. Why: screenshots go
> stale silently (no gate can diff pixels against the UI), die on every
> redesign, and are unverifiable; the UI is server-rendered so exact labels are
> greppable and gate-checked instead (`TestUIControlLabels`). The prose
> standard teaches patterns that survive re-layouts. Kept below unmodified for
> history.
>
> **Last Updated**: 2026-02-03 > **Last Updated**: 2026-02-03
This document outlines the complete plan for automatically generating screenshots for Bookhoard documentation using Playwright. This document outlines the complete plan for automatically generating screenshots for Bookhoard documentation using Playwright.
+3 -3
View File
@@ -4,7 +4,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
## 🚀 Getting Started ## 🚀 Getting Started
- **[Development Guide](Development.md)** - Development workflow and architecture - **[Development Guide](development.md)** - Development workflow and architecture
- Architecture overview - Architecture overview
- Directory structure - Directory structure
- Local development setup - Local development setup
@@ -14,7 +14,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
## 📋 Project Guidelines ## 📋 Project Guidelines
- **[AGENTS.md](../../AGENTS.md)** - Agent/development rules - **[AGENTS.md](../contributing/contributing.md)** - Development rules and standards live in the Contributing portal
- Critical prohibitions - Critical prohibitions
- Mandatory requirements - Mandatory requirements
- Error recovery protocol - Error recovery protocol
@@ -22,7 +22,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
## 🤝 How to Contribute ## 🤝 How to Contribute
We welcome contributions! Please see our [Development Guide](Development.md) for information on: We welcome contributions! Please see our [Development Guide](development.md) for information on:
- Setting up your development environment - Setting up your development environment
- Understanding the codebase - Understanding the codebase
+3 -3
View File
@@ -510,9 +510,9 @@ DEBUG=true
## 📚 Additional Resources ## 📚 Additional Resources
- [AGENTS.md](../../AGENTS.md) - Agent/development rules - [Development rules and standards](contributing.md) - Contributing portal
- [API Reference](../API_REFERENCE.md) - Complete API documentation - [API Reference](../developer/api/api-reference.md) - Split endpoint docs, indexed from its overview
- [Troubleshooting](../TROUBLESHOOTING.md) - Deployment issues - [Troubleshooting](../operations/troubleshooting.md) - Deployment issues
- [Go Documentation](https://go.dev/doc/) - [Go Documentation](https://go.dev/doc/)
- [Echo Framework](https://echo.labstack.com/docs) - [Echo Framework](https://echo.labstack.com/docs)
- [pgx Documentation](https://pgx.github.io/pgx/) - [pgx Documentation](https://pgx.github.io/pgx/)
+230
View File
@@ -0,0 +1,230 @@
<!-- Gate-checked by TestUIControlLabels (internal/docs/ui_labels_test.go):
every `controls` entry must exist as a literal in templates/*.templ
(or web/src for source: ts). Renames fail make test. Entry text is the
canonical spelling cited across the docs. -->
# UI controls glossary
The canonical spellings for controls cited across the UI documentation. Docs
quote the label exactly as this page lists it — one entry per control; two
spellings of one control is a defect. The `controls` block is machine-read:
when a doc first names a control, add its entry in the same commit; when a
rename lands, update the entry in the same commit and the gate goes green
again.
Entry fields:
- `label` — the exact rendered text users see
- `where` — semantic path (page → section/card), never spatial; see the
[writing standard](writing-ui-docs.md)
- `does` — what interacting with it produces (the outcome)
- `source` — optional; default `templ` (`templates/*.templ`); `ts` searches `web/src`
- `also` — optional, pipe-separated extra literal fragments to search when
the visible label is Alpine-composed rather than static (OR-checked with `label`)
- `note` — optional clarifier
Form-field labels inside a single modal (Name, Description…) are cited
inline in docs and not tracked here; this list carries the controls people
navigate by.
## Dashboard, profile & podcasts
```controls
label: Customize Dashboard
where: Library bar on the dashboard (button); opens the Customize Dashboard panel
does: Opens per-library shelf arrangement — drag order, show/hide switches, items per section.
label: Save Changes
where: Customize Dashboard panel → submit
does: Saves that library's shelf arrangement.
label: Refresh
where: Library bar on the dashboard (button)
does: Re-reads libraries without a full page reload.
note: icon button with a tooltip title
label: Create Custom Section
where: the custom-section builder page (/custom-section) → page title
does: Page where a dashboard shelf is defined by filter rules or hand-picked books.
label: Update Password
where: profile page → Change Password → submit
does: Saves the new password after live requirement checks.
label: Remove My Account
where: profile page → Danger Zone
does: Deletes the account and all personal data after confirmation; blocked for the last admin.
label: Add Feed
where: Podcasts page
does: Opens the Add Podcast Feed panel.
label: Add & Subscribe
where: Add Podcast Feed panel → submit
does: Subscribes the feed and starts watching it for new episodes.
also: Add &amp; Subscribe
note: templ source stores the ampersand entity; the rendered label reads "Add & Subscribe"
label: Download all episodes now
where: Add Podcast Feed panel
does: Grabs the feed's back catalog immediately on subscribe.
label: Player settings
where: audio player → settings control
does: Opens the per-player preferences — Autoplay on open, Default playback speed, Resume rewind.
also: title="Settings"
label: Shortcuts & interactions
where: audio player → the ? control
does: Lists every keyboard shortcut the player supports.
also: Shortcuts &amp; interactions
note: templ source stores the ampersand entity
label: Chapters
where: audio player → chapter-list control
does: Opens the chapter list and shows the current chapter position.
note: title attribute on the control
label: Bookmarks
where: audio player → bookmarks control
does: Opens your markers for this title; tap one to jump back.
note: title attribute on the control
```
## Collections
```controls
label: New Collection
where: Collections page
does: Opens the create-collection modal.
note: the empty page offers the same action as "Create Your First Collection"
label: Create Your First Collection
where: Collections page, when no collections exist
does: Opens the create-collection modal.
label: Create Collection
where: create-collection modal → submit
does: Saves the new collection and returns to the Collections page.
label: Update Collection
where: edit-collection modal → submit
does: Saves your changes to the collection.
label: Static
where: create-collection modal → Type
does: Chooses a hand-picked collection — you add and remove items yourself.
label: ⚡ Smart
where: create-collection modal → Type; also shown as a badge on smart collections page and detail
does: Chooses a rules-matched collection — its membership stays current by itself.
label: Rules scope
where: create-collection modal, when Smart is chosen
does: Asks what the rules ask about — the two buttons below it.
also: 📚 Books|🎧 Podcast episodes
note: "Books" and "Podcast episodes" as the two scope buttons
label: Restore System
where: Collections page
does: Opens a pick-a-shelf modal to bring back a system collection you deleted.
label: Restore
where: Restore System Collection modal → submit
does: Brings the selected system collection back.
label: Edit Rules
where: collection detail page
does: Opens the smart-collection rules editor for this collection.
label: Filter tree
where: smart-collection rules editor
does: The area where the group and rules are built.
note: read/write rules — the how is the filter guide
label: Apply to Collection
where: smart-collection rules editor → save
does: Saves the filter tree and makes the collection match those rules from now on.
label: Add Items
where: collection detail page
does: Opens the item picker window.
label: Add Selected
where: item picker → footer
does: Adds everything checked in the picker to the collection.
label: Remove Selected
where: collection detail page
does: Opens a confirm dialog to remove the checked items from the collection.
label: Search within collection...
where: collection detail page → search box
does: Filters the displayed items — nothing is removed.
label: Play as Sequence
where: collection detail page (when it has a downloaded episode)
does: Plays the collection's episodes one after another in the listening order you pick.
label: Hide finished books (reading-queue mode)
where: edit-collection modal
does: Finished books disappear from the collection's shelf.
label: Show Up Next strip
where: edit-collection modal
does: Adds this collection's Up Next strip to the podcast page.
```
## Everywhere else
```controls
label: Filters
where: All Books / Podcasts page → toolbar
does: Opens the filter drawer for building and applying rules.
label: Register Device
where: Devices page
does: Submits a device registration request for the pending-approval flow.
label: Download All
where: Podcast page → episodes area
does: Queues every episode of the feed for download.
label: Download order
where: Add Podcast Feed panel; podcast feed settings
does: Sets the ordering used when feed episodes are downloaded.
label: Match ALL / Group ANY header
where: filter drawer → group header (also smart collections → Rules and dashboard sections)
does: Sets how the group combines its rules; clicking the header flips ALL/ANY.
also: ? 'Match' : 'Group'|? 'ANY' : 'ALL'
note: label is Alpine-composed — 'Match' for the whole builder, 'Group' for nested; docs write it as MATCH ALL / MATCH ANY
label: Reset to Scanned
where: book detail → edit modal → scan information area
does: Restores scanner-extracted metadata over manual edits.
label: Auto-Scan
where: Settings page → Scanning section
does: Enables automatic periodic library scanning on the interval below it.
label: Scan Interval (seconds)
where: Settings page → Scanning section
does: Sets the seconds between automatic scans.
label: Resume rewind
where: audio player → player settings; profile settings
does: Rewinds playback a configured few seconds when you resume.
also: Resume Rewind
note: profile page spells it capitalized
label: Reading Direction
where: book detail → edit modal
does: Chooses the direction pages advance (publisher convention — e.g. manga).
label: Scan Info
where: book detail page → info block
does: Displays scanner-supplied notes attached to the item.
also: Scan Information
note: the editable form label in the modal reads "Scan Information"
```
+117
View File
@@ -0,0 +1,117 @@
# Writing UI documentation without screenshots
Bookhoard's UI documentation uses no screenshots. This page is the standard
for writing them. The flagship example of the style is the
[filter system documentation](../user/filters/index.md): one system taught
once, the places it appears listed as a *pattern*, samples for every field in
a [generated reference](../user/filters/fields.md) that cannot drift from the
UI.
## Why no screenshots
Screenshots fail in every way the rest of this repo's rot is already gated
against:
- **They rot silently.** No gate can diff pixels against the running UI. A
renamed button makes the screenshot wrong and nothing fails.
- **They cannot be verified.** Shown text can: this app's UI is
server-rendered from `templates/*.templ`, so every stable label and heading
is a literal in the source — checkable, like routes and API examples are.
- **They die with a redesign.** Text keyed to *function* survives a
re-layout. Text keyed to a layout dies with its redesign.
- **They exclude** (screen readers, text search) and bloat diffs.
The one thing screenshots do better — "which button is it?" — text does via
**exact quoted labels** plus a semantic path, and the label gate then holds
that text against the source.
## The layering — what is load-bearing
Write every UI page in this order. The top layers are why users don't get
lost when the UI changes:
| # | Layer | Content | Lifespan |
| --- | --- | --- | --- |
| 1 | **The pattern** | What the thing *is*, what is invariant, every place the same pattern appears | Redesign-proof |
| 2 | **Function inventory** | What the user *can do*, phrased without control names | Redesign-proof |
| 3 | **How-to with examples** | Numbered flows: current label → the outcome it produces | Survives most changes |
| 4 | **Current labels** | Exactly quoted, bold | Gate-checked today |
| 5 | **Generated references** | Enumerables regenerated from source with a currency test | Cannot drift |
Labels are **tips, not the map**. Layer 3 may quote a label; layer 1 must
make the page followable even if that label is renamed next week.
## The page template
1. **The pattern first.** The concept, its invariants, and everywhere it
appears — "one system, three places". Never page cartography.
2. **Function inventory.** Capabilities without control names: "you can pin
any narrowing to a named filter you can reopen later".
3. **How-to with examples.** Each step: the label of what you interact with,
then the outcome ("the count at the bottom shows how many books will
match"). An example beats an adjective.
4. **Findability note.** One line telling the reader how to re-locate the
feature *by function again* after any UI change — deliberately
label-independent ("it's the drawer opened from any books or podcasts
list — the only place whose header can read Match ALL").
5. **Current labels.** Quoted exactly as rendered, one canonical spelling
per control ([arbitrated by the glossary](ui-controls.md)).
## Writing rules
- **Semantic placement, never spatial.** No "top-right" or "at the bottom of
the screen" talk. "Devices page → the **Register Device** button" survives
any re-layout.
- **One canonical label per control.** Two spellings of the same control is
a defect, not a synonym.
- **Status updates get a what-it-means table**, not a color description.
- **Screenshots are never load-bearing.** Default is none.
## Tone by audience
- **User Guide (`docs/user/`) — plain language, deliberately.** Short
sentences. Everyday words. No developer jargon; any term the reader
genuinely needs (say, *rule*) is explained once, in place, then used
consistently. The reader is assumed to know books, while the app's own
words are the only technical vocabulary allowed.
- **Developer and Administration sections — tone is a separate discussion,
not yet decided.** Write them plainly and hand-off-rich as well; do not
import opinions about their voice until that pass happens.
## Generate what has a registry
Anything enumerable gets generated from source with a currency test, not
hand-typed. The precedent: every filter field is
[generated](../user/filters/fields.md) by `TestFieldReferenceDocIsCurrent`
from the same registry the menus render — the doc cannot disagree with the
UI. Hand-typed lists drift fastest; when no registry exists yet, say so in
the page and lean on the pattern layers.
## The gates
- `TestUIControlLabels` (`internal/docs/ui_labels_test.go`): every control in
the [glossary](ui-controls.md) must exist as a literal in
`templates/*.templ` (or `web/src` for TS-rendered surfaces, marked
`source: ts`). Renames fail `make test` in the same commit as the rename.
- `TestDocsRelativeLinks`: every relative link in the docs tree resolves to
a real file.
Naming a new control in prose? Add its glossary entry in the same commit.
Form labels inside one modal (Name, Description…) are cited inline and not
glossary-tracked; the glossary carries the controls people navigate by —
buttons, tabs, badges, page titles.
## The process rule
**A UI change is not finished until the docs audit runs.** Moved card,
renamed button, restructured page — all of it triggers: labels still
resolve, links still resolve, affected pages re-read against the new
surface. The pattern layers keep users oriented between audits; the audit
keeps layers 3–5 honest.
## What this does not replace
The filter documentation (and the older filtering overview) is hands-off
exemplar territory — read it as the model, never restyle it here. New UI
surfaces (scanning, devices, settings, podcasts, the reader, collections)
are written this way from the start.
@@ -623,6 +623,6 @@ This is a great example of how user feedback can improve implementation design!
## See Also ## See Also
- [User Guide: Calibre Integration](../../user/calibre-integration.md) - [User Guide: Calibre Integration](../user/calibre-integration.md)
- [Implementation Plan](../../CALIBRE_OPF_IMPLEMENTATION.md) - The original implementation plan file was removed from the repo when this page absorbed its content.
- [Scanner Source Code](../../internal/services/media_scanner.go) - The scanner implementation lives in `internal/services/` (not hyperlinked — the docs site serves only docs).
+38 -80
View File
@@ -13,6 +13,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
- **Device Setup** - **Device Setup**
- [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook hardware - [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook hardware
- [Kobo Setup Guide](user/devices/kobo-setup.md) - Native Kobo sync (coming soon; use KOReader today) - [Kobo Setup Guide](user/devices/kobo-setup.md) - Native Kobo sync (coming soon; use KOReader today)
- [Android App](user/devices/android-app.md)
- **Sync Configuration** - **Sync Configuration**
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts - [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
@@ -22,53 +23,50 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
- [Filter cookbook](user/filters/cookbook.md) - ready-made recipes, exact clicks - [Filter cookbook](user/filters/cookbook.md) - ready-made recipes, exact clicks
- [Every filter field](user/filters/fields.md) - the generated, always-current reference - [Every filter field](user/filters/fields.md) - the generated, always-current reference
- **Frontend & Settings** (Coming Soon) - **Guides**
- Frontend Guide - [Reader Guide](user/reader-guide.md) - reading, highlights, notes
- User Areas Guide - [Library Browsing](user/library-browsing.md) - browsing and searching your library
- Settings Guide - [Collections](user/collections.md) - manual and ⚡ smart shelves, books and episodes
- Admin Guide - [Podcasts](user/podcasts.md) - feeds, downloads, listening
- [Dashboard](user/dashboard.md)
- [Themes](user/themes.md)
- [Calibre Integration](user/calibre-integration.md)
- [Profile Guide](user/profile-guide.md)
- [Admin Guide](user/admin-guide.md)
### 👨‍💻 For Developers ### 👨‍💻 For Developers
**[Developer Documentation Portal](developer/development.md)** - Technical documentation & API reference **[Developer Documentation Portal](developer/development.md)** - Technical documentation & API reference
- **API Documentation** - **API Documentation** — every endpoint is documented individually; the links below are entry points
- [Complete API Reference](developer/api-reference.md) - Monolithic REST API reference (1,300+ lines) - [API Reference overview](developer/api/api-reference.md) - index of all endpoint docs
- [Split Endpoint Docs](developer/api/api-reference.md) - Individual endpoint docs - [Collections API](developer/api/collections/list_collections.md) - collections management API
- [Collections API](developer/api/collections/list_collections.md) - Collections management API - [Media Items API](developer/api/media-items/list_media_items.md) - media item operations including bulk operations and downloads
- [Admin API](developer/api/admin/) - User management (admin operations) - [Devices API](developer/api/devices/list_devices.md) - Device sync and shelf management
- [Media Items API](developer/api/media-items/) - Media item operations including bulk operations and downloads - [Conflicts API](developer/api/conflicts/list_conflicts.md) - Sync conflict resolution endpoints
- [Devices API](developer/api/devices/) - Device sync and shelf management - [Queue API](developer/api/queue/list_all_queue_items.md) - Sync queue management endpoints
- [Conflicts API](developer/api/conflicts/) - Sync conflict resolution endpoints - [Scanner API](developer/api/scanner/overview.md) - Library scanning and automated watch mode (admin)
- [Queue API](developer/api/queue/) - Sync queue management endpoints - [KOReader API](developer/api/koreader/get_library.md) - KOReader sync protocol endpoints
- [Scanner API](developer/api/scanner/) - Library scanning and automated watch mode (admin) - [Kobo API](developer/api/kobo/sync_from_server.md) - Kobo sync protocol endpoints
- [KOReader API](developer/api/koreader/) - KOReader sync protocol endpoints - [Admin API](developer/api/admin/list_users.md) - User management (admin operations); content-duplicate cleanup lives in [hash conflicts](developer/api/admin/hash_conflicts_api.md)
- [Kobo API](developer/api/kobo/) - Kobo sync protocol endpoints (feature coming soon)
- [WebSocket API](developer/api/websocket/) - Real-time sync events
- **Protocol Specifications** - **Protocol Specifications**
- [KOReader Sync Protocol](developer/api/sync/koreader-protocol.md) - KOReader sync - [KOReader Sync Protocol](developer/api/sync/koreader-protocol.md) - KOReader sync
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync (coming soon) - [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync
- [WebSocket API](developer/websocket-api.md) - Real-time events
- **Writing UI docs?** Read [Writing UI documentation without screenshots](developer/writing-ui-docs.md) first.
### 🔧 For Operations ### 🔧 For Operations
**[Operations Documentation Portal](operations/operations.md)** - Deployment & maintenance **[Operations Documentation Portal](operations/operations.md)** - Deployment & maintenance
- **Deployment**
- [Troubleshooting Guide](operations/troubleshooting.md) - Common deployment issues and solutions - [Troubleshooting Guide](operations/troubleshooting.md) - Common deployment issues and solutions
- **Maintenance** (Coming Soon)
- Deployment Guide
- Maintenance Guide
- Monitoring Guide
### 🤝 For Contributors ### 🤝 For Contributors
**[Contributing Portal](contributing/contributing.md)** - Development workflow **[Contributing Portal](contributing/contributing.md)** - Development workflow
- [Development Guide](contributing/development.md) - Architecture, setup, testing - [Development Guide](contributing/development.md) - Architecture, setup, testing, and the project rules
- [../../AGENTS.md](../../AGENTS.md) - Development rules and standards
--- ---
@@ -76,11 +74,11 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
| Want to... | Go to | | Want to... | Go to |
| -------------------- | ---------------------------------------------------------------------- | | -------------------- | ---------------------------------------------------------------------- |
| **Get started** | [README.md](../README.md) - Project overview and quick start | | **Get started** | [Development Guide](contributing/development.md) - Architecture and quick start |
| **Set up a device** | [User Portal → Device Setup](user/user-guide.md) | | **Set up a device** | [User Portal → Device Setup](user/user-guide.md) |
| **Use the API** | [Developer Portal → API Docs](developer/development.md) | | **Use the API** | [API Reference overview](developer/api/api-reference.md) |
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) | | **Deploy Bookhoard** | [Operations → Troubleshooting](operations/troubleshooting.md) |
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) | | **Contribute code** | [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) | | **Read in the browser** | [User Portal → Web Reader Guide](user/reader-guide.md) |
@@ -92,65 +90,25 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
| Question | Answer | | Question | Answer |
| --------------------------- | ------------------------------------------------------ | | --------------------------- | ------------------------------------------------------ |
| ...install Bookhoard? | [README.md](../README.md) - Quick Start |
| ...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 | | ...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 overview](developer/api/api-reference.md) |
| ...write UI docs? | [Writing UI documentation](developer/writing-ui-docs.md) |
| ...contribute code? | [Development Guide](contributing/development.md) | | ...contribute code? | [Development Guide](contributing/development.md) |
### "Where is..."
| Information | Location |
| ---------------------- | ----------------------------------------------------------- |
| ...features list? | [README.md](../README.md) |
| ...database schema? | [database/schema/schema.sql](../database/schema/schema.sql) |
| ...API endpoints? | [API Reference](developer/api-reference.md) |
| ...secrets config? | [.env.example](../.env.example) |
| ...operational config? | [docker-compose.yml](../docker-compose.yml) |
| ...deployment issues? | [Troubleshooting Guide](operations/troubleshooting.md) |
---
## 🎯 Common Workflows
### Set up a new device
1. Choose your device: [KOReader](user/devices/koreader-setup.md) (works on Kindle, Kobo, and PocketBook hardware)
2. Understand sync: [Sync Guide](user/sync-guide.md)
3. Troubleshoot: Device-specific guides
### Integrate with Bookhoard API
1. Start with [API Reference](developer/api-reference.md)
2. Explore [Collections API](developer/api/collections/list_collections.md)
3. Review [WebSocket API](developer/websocket-api.md)
4. Test with [bruno/](../bruno/) collections
### Deploy to production
1. Follow [README.md](../README.md) quick start
2. Configure environment: [.env.example](../.env.example)
3. Review [Troubleshooting Guide](operations/troubleshooting.md)
4. Check [Development Guide](contributing/development.md) for performance tuning
--- ---
## 📝 Contributing to Documentation ## 📝 Contributing to Documentation
When adding new features: When adding new features:
1. **User-facing features** → Update relevant User docs 1. **User-facing features** → Update the relevant User docs
2. **API endpoints** → Update [API Reference](developer/api-reference.md) & split docs 2. **API endpoints** → Update the endpoint docs under `developer/api/` (start from the [API Reference overview](developer/api/api-reference.md))
3. **Backend changes** → Update [Development Guide](contributing/development.md) 3. **Backend changes** → Update the [Development Guide](contributing/development.md)
4. **Deployment changes** → Update [Operations Portal](operations/operations.md) 4. **Deployment changes** → Update the [Operations Portal](operations/operations.md)
5. **New UI controls in docs** → Add them to the [UI controls glossary](developer/ui-controls.md)
Keep [../../AGENTS.md](../../AGENTS.md) in mind for documentation standards. 6. **UI appearances in docs** → Follow [Writing UI documentation without screenshots](developer/writing-ui-docs.md)
---
**Last Updated**: August 2026
**Bookhoard Version**: 1.0
+3 -3
View File
@@ -15,19 +15,19 @@ Welcome to the Bookhoard operations documentation. This section contains guides
### Additional Deployment Guides ### Additional Deployment Guides
**[Deployment Guide](deployment.md)** - Comprehensive deployment guide **Deployment Guide** - Comprehensive deployment guide
- _Coming Soon_ - _Coming Soon_
## 🛠️ Maintenance ## 🛠️ Maintenance
**[Maintenance Guide](maintenance.md)** - Ongoing operations and maintenance **Maintenance Guide** - Ongoing operations and maintenance
- _Coming Soon_ - _Coming Soon_
## 📊 Monitoring ## 📊 Monitoring
**[Monitoring Guide](monitoring.md)** - Monitoring and alerting **Monitoring Guide** - Monitoring and alerting
- _Coming Soon_ - _Coming Soon_
+1 -1
View File
@@ -13,4 +13,4 @@ This guide will cover:
--- ---
_In the meantime, check out our [Operations Portal](../operations/)_ _In the meantime, check out our [Operations Portal](../operations/operations.md)_
+4 -5
View File
@@ -139,7 +139,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
**Steps**: **Steps**:
1. Add the Calibre library folder in Bookhoard (Administration → Libraries → expand the library → **Folders**) 1. Add the Calibre library folder in Bookhoard (Administration → Libraries → expand the library → **Folders**)
2. Trigger a scan via the **Scanner API**, or let watch mode pick up the changed files (the File Watcher status is shown on the admin dashboard) 2. Trigger a scan from the admin interface's scan controls, or let watch mode pick up the changed files automatically
3. Bookhoard imports all 500 books with: 3. Bookhoard imports all 500 books with:
- Correct titles and authors - Correct titles and authors
- Series information (e.g., "Harry Potter #2") - Series information (e.g., "Harry Potter #2")
@@ -168,7 +168,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
**Steps**: **Steps**:
1. Edit metadata in Calibre (it updates `metadata.opf`) 1. Edit metadata in Calibre (it updates `metadata.opf`)
2. In Bookhoard, trigger a rescan: 2. In Bookhoard, trigger a rescan:
- Via the **Scanner API** (`POST /api/scanner/scan`), or - Via the admin interface's scan controls, or
- Let watch mode detect the changed files automatically (see File Watcher on the admin dashboard) - Let watch mode detect the changed files automatically (see File Watcher on the admin dashboard)
3. Bookhoard detects updated `metadata.opf` and refreshes metadata 3. Bookhoard detects updated `metadata.opf` and refreshes metadata
@@ -183,7 +183,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
**Solutions**: **Solutions**:
1. **Check file structure**: Ensure `metadata.opf` is in the same folder as the book file 1. **Check file structure**: Ensure `metadata.opf` is in the same folder as the book file
2. **Verify library type**: Ensure library type matches content (ebook vs. audiobook) 2. **Verify library type**: Ensure library type matches content (ebook vs. audiobook)
3. **Force rescan**: Trigger a scan via the Scanner API to re-import all metadata (watch mode also picks up changed files automatically) 3. **Force rescan**: Trigger a rescan from the admin interface to re-import all metadata (watch mode also picks up changed files automatically)
4. **Check logs**: Review Bookhoard logs for parsing errors 4. **Check logs**: Review Bookhoard logs for parsing errors
### Incorrect Metadata ### Incorrect Metadata
@@ -332,11 +332,10 @@ file is never touched if anything fails (temp file + atomic rename).
- [Calibre Website](https://calibre-ebook.com/) - [Calibre Website](https://calibre-ebook.com/)
- [Calibre User Manual](https://manual.calibre-ebook.com/) - [Calibre User Manual](https://manual.calibre-ebook.com/)
- [Calibre Forum](https://www.mobileread.com/forums/forumdisplay.php?f=166) - [Calibre Forum](https://www.mobileread.com/forums/forumdisplay.php?f=166)
- [Bookhoard GitHub](https://github.com/yourusername/bookhoard)
## Need Help? ## Need Help?
- **Documentation**: See [User Guide](user-guide.md) for general Bookhoard usage - **Documentation**: See [User Guide](user-guide.md) for general Bookhoard usage
- **Troubleshooting**: See [Operations Guide](../operations/operations.md) for common issues - **Troubleshooting**: See [Operations Guide](../operations/operations.md) for common issues
- **Developer Docs**: See [Developer Portal](../developer/development.md) for technical details - **Developer Docs**: See [Developer Portal](../developer/development.md) for technical details
- **Issues**: Report bugs or request features on [GitHub Issues](https://github.com/yourusername/bookhoard/issues) - **Issues**: Report bugs or request features on your repository's issue tracker
+101 -25
View File
@@ -1,42 +1,118 @@
# Collections # Collections
Collections allow you to organize books across multiple libraries. A collection is a personal shelf you name yourself. You put your books and
podcast episodes on it, and it appears as a section on your dashboard.
## Viewing Collections That is the whole idea. Everything below is variations on it.
### From Collections Page ## The one pattern, four flavors
Navigate to **Collections** (sidebar navigation) to see all your collections. Clicking on a collection shows ALL books in that collection across all libraries. Every collection behaves the same way on the outside: it has a name, an icon,
a color, and a list of items. The flavors differ in how items *join*:
### From Dashboard | Flavor | How items join | Right for |
| --- | --- | --- |
| **Static** | You pick each item by hand | Curated lists — "books for the trip", "new house ideas" |
| **⚡ Smart** (books) | Rules match books live, forever | "everything by Asimov", "still not started", "over 400 pages" |
| **⚡ Smart** (podcast episodes) | Rules match episodes live — and they can play back-to-back | "catch up on my feeds", "episodes to finish" |
| **System** (built-in) | Bookhoard maintains them | Continue Reading, Recently Added, Recently Read, Not Started, Continue Series |
When viewing a specific library's dashboard, collections only show books from that library. Clicking "View All" preserves this context and shows only books from that library in the collection. A collection holds **books and podcast episodes** — mix them if you like.
### Library Filtering The **⚡ Smart** badge means the same thing everywhere it appears: *membership
is matched by rules right now — always current, nothing to refresh*.
- **No filter** (`/collections/favorites`): Shows all books across all libraries ## Creating a collection
- **With filter** (`/collections/favorites?library_id=xxx`): Shows only books from the specified library
## Creating Collections 1. Open the **Collections** page (sidebar navigation)
2. Click **New Collection**
3. Choose the **Type**:
- **Static** — pick items by hand
- **⚡ Smart** — match by rules, always live
4. If you picked **⚡ Smart**, choose the **Rules scope**: **📚 Books** or
**🎧 Podcast episodes**
5. Fill in the rest: **Name**, **Description**, an **Icon** you can search or
pick, and a **Color**
6. Click **Create Collection**
1. Go to **Collections** (sidebar navigation) Starting from a completely empty account? The button **Create Your First
2. Click **New Collection** (top-right) — or **Create Your First Collection** if the list is empty Collection** does the same thing.
3. Fill in the details:
- **Name** - Collection name
- **Description** - Optional description
- **Icon** - Pick from the icon grid
- **Color** - Pick a color swatch
4. Click **Create Collection**
## Managing Collections ## Adding items by hand (Static collections)
Each collection in the list has icon buttons on its card: 1. Open the collection from the **Collections** page
2. Click **Add Items**
3. Pick the tab for what you want:
- **📚 Books** — searchable by the **Search books...** box, and the
**Author** and **Genre** boxes
- **🎧 Episodes** — the episodes of the feeds you subscribe to
4. Click one or more items — a checkmark marks what you picked; the bottom
of the window counts them for you
5. Click **Add Selected**
- **Edit** - Opens the edit form to change name, description, icon, or color. Click **Update Collection** to save. To take items out, check them on the collection page and click
- **Delete** - Removes the collection after a confirmation prompt. **Remove Selected** (it asks first).
Deleted a system collection by mistake? The **Restore System** button on the Collections page brings back system collections. While checking what is in the collection, the **Search within collection...**
box narrows the view — it never removes anything.
### Dashboard Sections ## Rules (how ⚡ Smart collections work)
Collections appear as sections on your dashboard. Show, hide, and reorder them from the dashboard's **Customize Dashboard** settings (see [Dashboard](dashboard.md)). A smart collection keeps its own list of rules, the same rules that power the
filter drawer. On the collection's page, click **Edit Rules** to open the
rules editor: build your groups and rules in the **Filter tree**, then click
**Apply to Collection**. From that point the collection matches those rules
by itself — add a book that matches and it appears without you doing
anything.
Rules live in one guide, so this page does not repeat them. The full how-to
walks through groups, ALL/ANY, and value picking in
[Filters — the complete guide](filters/index.md), ready-made examples are in
the [cookbook](filters/cookbook.md), and
[every filter field](filters/fields.md) lists what you can ask about.
*Finding it again later:* open any collection's page and look for the
**Edit Rules** button; the **⚡ Smart** badge in the page title marks a
collection that is already matching.
## Playing an episode collection
When a collection holds a downloaded episode, it grows a **Play as Sequence**
button: the episodes play one after another. Next to it, the order picker
decides the sequence — **Feed order**, **Feed order (reversed)**,
**Oldest first (pub date)**, or **Newest first (pub date)**. Your choice is
remembered on the collection.
## Changing a collection
The pencil button on a collection card, or the edit control on the
collection page, opens the edit form:
- Change the **Name**, **Description**, **Icon**, **Color**
- **Hide finished books (reading-queue mode)** — finished books drop off
this shelf, which keeps a to-read list short
- **Show Up Next strip** — show the collection's Up Next strip where it is
used
Click **Update Collection** to save. Deleting a collection asks first, and
deleting only removes the shelf — never the books or episodes inside it.
## System collections
The five built-in shelves — **Continue Reading**, **Recently Added**,
**Recently Read**, **Not Started**, **Continue Series** — work like any
collection on the outside, but Bookhoard decides their membership by what
happens while you read. They appear on your dashboard automatically.
Deleted one and want it back? Click **Restore System** on the **Collections**
page, pick the shelf from the list, and click **Restore**.
## Your dashboard shelves
Collections appear as sections on your dashboard, and you choose which ones
show and in what order from the dashboard's own settings. That page's
details live in [Dashboard](dashboard.md).
*Finding your way back:* **Collections** is the sidebar page whose top has
the only **New Collection** button — from a dashboard shelf's **View All**,
the collection detail page is the place with **Add Items**.
+45 -35
View File
@@ -1,52 +1,62 @@
# Dashboard # Dashboard
The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library. The dashboard is your home screen: horizontal shelves ("sections") of your
books, one row after another. What each shelf contains depends on the library
you are looking at — including the **All Libraries** view, which mixes
everything you can see.
## Sections ## The three kinds of section
### Smart Sections | Kind | What fills it |
| --- | --- |
| **System shelves** | Bookhoard maintains them from your reading: **Continue Reading**, **Recently Added**, **Recently Read**, **Not Started**, **Continue Series** |
| **Your collections** | Every collection you create appears as its own shelf |
| **Custom sections** | Shelves you define yourself — by filter rules or by hand-picking books |
Smart sections are automatically generated based on your reading activity: System shelves and collections are covered by their own guides (see
[Sync Guide](sync-guide.md) for what "continue reading" means across devices,
and [Collections](collections.md) for the shelves you build). This page is
about arranging the rows.
- **Continue Reading**: Books you're currently reading (progress between 0-100%) ### This Week
- **In Progress**: Books you've started (progress > 0%)
- **Recently Added**: Newest items added to this library
- **Recently Read**: Books you've completed (100% progress)
- **Not Started**: Books you haven't read yet
### User Collections The dashboard also shows a small **This Week** summary — your reading time
and activity for the week so far. Click it to expand the detail.
Your collections appear as sections on the dashboard. To show or hide a collection's section: ## Customizing your shelves
1. Select the library in the **Library** bar 1. Pick the library in the **Library** bar (a specific library, or
2. Click the **Customize Dashboard** icon button **All Libraries**) — settings are saved per library
3. Toggle the collection on or off 2. Click the **Customize Dashboard** button in the Library bar
4. Click "Save Changes" 3. In the **Customize Dashboard** panel: drag sections into the order you
want, switch sections on or off, and set how many items each section
shows (10–50)
4. Click **Save Changes**
### Customizing Your Dashboard The **Refresh** button next to it re-reads your libraries without a full
page reload.
1. In the **Library** bar below the top bar, select the library you want to customize (a specific library, not "All Libraries") ## Custom sections
2. Click the **Customize Dashboard** icon button at the right end of the Library bar (next to Refresh)
3. **Drag sections** to reorder them
4. **Toggle visibility** with the switches
5. **Adjust items per section** (10-50 items)
6. Click "Save Changes"
Settings are saved per library. A custom section is a shelf whose contents you define — either with the same
filter rules the **Filters** drawer uses, or by picking the books yourself.
You build one on the **Create Custom Section** page (at `/custom-section` on
your server); once saved it behaves like any other shelf and can be ordered
and hidden with everything else.
### Library Switching ## Reading and listening from the dashboard
Use the **Library** dropdown in the bar below the top bar to switch between libraries (including "All Libraries"). Each library has its own dashboard settings. Point at a shelf and chevron buttons appear to scroll it. Every item carries
a direct action: **Read** opens the web reader, **Listen** opens the audio
player. If the same book has conflicting progress from two devices, the item
grows a resolve action that takes you to conflict resolution — see the
[Sync Guide](sync-guide.md).
### Keyboard Navigation ## Keyboard and touch
- **Tab**: Navigate between sections and books - **Tab** moves between shelves and books; **Enter** opens the focused book
- **Arrow Keys**: Scroll carousels horizontally - **Arrow keys** scroll the focused shelf sideways
- **Enter**: Open selected book - On touch: swipe a shelf to scroll it, tap a cover to open it
Hovering a carousel shows chevron buttons on either side for scrolling. *Finding your way back:* the dashboard is the page with the **Library** bar —
the only place with the **Customize Dashboard** button.
### Touch Gestures (Mobile)
- **Swipe**: Drag carousel left/right to scroll
- **Tap**: Open book details
+26 -19
View File
@@ -1,40 +1,47 @@
# Kobo Device Setup Guide # Kobo Device Setup Guide
> ## 🚧 Coming Soon There are two ways to use a Kobo with Bookhoard, and they can be chosen
> independently per device:
> Native Kobo sync is not available yet. It is actively being developed and this guide will be filled in as the feature lands.
## Using a Kobo With Bookhoard Today | Way | Status | What you get |
| --- | --- | --- |
| **KOReader on the Kobo** | Works today, fully | Reading position, bookmarks, highlights, notes, OPDS wireless book delivery |
| **Stock Kobo firmware (native sync)** | Server support is complete; device-side enablement is the remaining piece | Reading position and stats, bookmarks/highlights/notes, shelf mappings (your collections as Kobo shelves), automatic EPUB → KEPUB conversion, and the Kobo's own sync experience |
You don't have to wait: **KOReader runs on Kobo hardware** and syncs fully with Bookhoard today — reading position, bookmarks, highlights, and notes, plus OPDS wireless book delivery. ## Using a Kobo today
See the **[KOReader Setup Guide](koreader-setup.md)** for complete instructions. **KOReader runs on Kobo hardware** and syncs fully with Bookhoard right
now — the [KOReader Setup Guide](koreader-setup.md) walks the whole path
(plugin install, server approval, OPDS delivery, troubleshooting).
## What's Planned for Native Kobo Sync ## About native Kobo sync
When released, native Kobo sync will let stock Kobo firmware talk directly to Bookhoard: The server side of the native protocol is implemented and documented: the
Kobo sync and initialization endpoints, bookmark and markup (highlight and
note) sync, shelf mappings that expose your collections as Kobo shelves,
and the KEPUB sidecar configuration used for conversion. What remains is
the device-side rollout, so stock-firmware sync is not yet something to
rely on for daily use — this guide will gain a full setup walkthrough when
that lands.
- **Reading position sync** — percentages, pages, and reading statistics Nothing about native sync takes anything away from KOReader: the two can
- **Bookmarks, highlights, and notes** — synced with the web and other devices coexist, and a KOReader setup keeps working either way.
- **OPDS wireless delivery** — browse and download books directly on the Kobo
- **Automatic EPUB → KEPUB conversion** — for better Kobo rendering
- **Shelf mappings** — Bookhoard collections appearing as Kobo shelves
The server-side protocol endpoints are already implemented and under test; the feature will be announced when it's ready for real devices.
## FAQ ## FAQ
**Q: Should I buy a Kobo to use with Bookhoard today?** **Q: Should I buy a Kobo to use with Bookhoard today?**
A: Kobo devices work great with Bookhoard via KOReader. Native (stock firmware) sync is coming soon. A: Yes, via KOReader — it is the supported path today and covers sync plus
wireless book delivery.
**Q: What happens to my KOReader setup when native sync arrives?** **Q: Will I have to redo anything when native sync lands?**
A: Nothing — KOReader will keep working. Native sync simply adds another option for people who prefer stock Kobo firmware. A: No — KOReader keeps working; native sync just adds the stock-firmware
option.
## Additional Resources ## Additional Resources
- [KOReader Setup Guide](koreader-setup.md) — works on Kobo today - [KOReader Setup Guide](koreader-setup.md) — works on Kobo today
- [Bookhoard Universal Sync Guide](../sync-guide.md) - [Bookhoard Universal Sync Guide](../sync-guide.md)
- [Bookhoard API Reference](../../developer/api-reference.md) - Kobo sync protocol (for developers): [Kobo API docs](../../developer/api/kobo/sync_from_server.md)
--- ---
+134 -28
View File
@@ -1,16 +1,24 @@
<!-- Ground truth: bookhoard.koplugin main.lua (menu tree, defaults, approval
flow) + the server-side koreader endpoints. Re-verify against the plugin
source when the plugin changes. -->
# KOReader Device Setup Guide # KOReader Device Setup Guide
This guide will help you set up KOReader on your e-reader device to sync with Bookhoard. This guide will help you set up KOReader on your e-reader device to sync with
Bookhoard — reading position, bookmarks, highlights, notes, and even your
ratings, reviews, and reading time.
## What is KOReader? ## What is KOReader?
KOReader is an open-source e-reader application that supports a wide range of e-reader devices including: KOReader is an open-source e-reader application that supports a wide range of
e-reader devices including:
- Kindle devices (Paperwhite, Oasis, Voyage, etc.) - Kindle devices (Paperwhite, Oasis, Voyage, etc.)
- Kobo devices (Clara, Aura, Nia, etc.) - Kobo devices (Clara, Aura, Nia, etc.)
- PocketBook devices - PocketBook devices
It also runs on Android tablets and phones, although Bookhoard's dedicated mobile apps (coming later) will be the better option there. It also runs on Android tablets and phones — though on Android the dedicated
[Bookhoard app](android-app.md) is the better option.
## Prerequisites ## Prerequisites
@@ -67,17 +75,27 @@ Before you begin, make sure you have:
## Connecting KOReader to Bookhoard ## Connecting KOReader to Bookhoard
Setup is done **on the server**: you approve the device from the Bookhoard web interface — no usernames, passwords, or tokens to type on the device. Setup is done **on the server**: you approve the device from the Bookhoard
web interface — no usernames, passwords, or tokens to type on the device.
### Step 1: Install the Bookhoard Plugin ### Step 1: Install the Bookhoard Plugin
1. Clone the [Bookhoard KOReader plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin) Installation is a git clone straight into your KOReader `plugins/` folder —
2. Copy it to your KOReader `plugins/` directory no download-and-copy step:
3. Restart KOReader
```bash
cd koreader/plugins # .adds/koreader/plugins on Kobo
git clone https://git.linuxhg.com/Bookhoard/bookhoard.koplugin
```
Then restart KOReader.
**Updating later** is the same, smaller: in the `bookhoard.koplugin` folder,
`git pull`, then restart KOReader.
### Step 2: Point the Plugin at Your Server ### Step 2: Point the Plugin at Your Server
1. Open KOReader, tap the **wrench icon** at the top 1. Open KOReader, tap the **wrench icon** at the top (the tools menu)
2. Find and tap **Bookhoard sync** 2. Find and tap **Bookhoard sync**
3. Tap **Server URL**, enter your server address, then tap **OK**: 3. Tap **Server URL**, enter your server address, then tap **OK**:
@@ -87,34 +105,116 @@ Setup is done **on the server**: you approve the device from the Bookhoard web i
Use your server's LAN IP (or domain if you have one configured). Use your server's LAN IP (or domain if you have one configured).
### Step 3: Approve the Device in Bookhoard ### Step 3: Register the Device
1. On your computer or phone, open Bookhoard and go to the **Devices** page (sidebar navigation) 1. In the same **Bookhoard sync** menu, tap **Register device**
2. Refresh the page — your device appears under **Pending Device Registrations** 2. On your computer or phone, open Bookhoard and go to the **Devices** page
3. Click **Approve** to connect the device (sidebar navigation)
3. Your device appears under **Pending Device Registrations** — click
**Approve** to connect it
Once approved, the plugin picks up its credentials automatically — reading progress sync and OPDS catalog access are set up automatically. No further configuration is needed. The plugin waits on the device and finishes by itself the moment you
approve. Once approved, its credentials are stored automatically, sync
events are armed, and Bookhoard's OPDS catalog is added to your KOReader —
no further configuration is needed.
> **Note:** Pending registrations expire after 5 minutes. If yours expires, just re-run the sync from the plugin menu and approve again. > **Note:** Pending registrations expire after 5 minutes. If yours expires
> ("Registration expired"), just tap **Register device** again and approve.
### Auth Token (Advanced) ### Auth Token (Advanced)
The Devices page shows each KOReader device's **Auth Token**. You normally never need it (the plugin receives it automatically during approval), but it can be re-entered manually in the plugin settings if you're moving a setup between devices or debugging. The Devices page shows each KOReader device's **Auth Token**. You normally
never need it (the plugin receives it automatically during approval), but it
can be re-entered manually in the plugin settings if you're moving a setup
between devices or debugging.
## The Bookhoard sync menu
Everything the plugin can do lives in one place: **wrench icon → Bookhoard
sync**. The menu changes shape depending on whether you are connected.
### Before you're connected
- **Server URL** — your server's address
- **Register device** — starts the approval flow above
### Once connected — one-tap actions
| Menu item | What it does |
| --- | --- |
| **Push progress from this device** | Force-push this device's position to the server |
| **Pull progress from server** | Force-pull the server's position onto this device |
| **Sync rating, review & time now** | Push your rating, review, and accumulated reading time for the current book immediately |
| **Setup OPDS catalog** | (Re-)adds Bookhoard's catalog to KOReader's OPDS list — normally done for you at approval |
| **Device info** | Shows this device's ID and the server it talks to |
| **Disconnect** | Forgets the device's credentials (after a confirmation) |
### Automatically push progress
This toggle is the on/off switch for background sync — **it starts off**, so
after approval, turn it on to get automatic syncing. With it on, progress
pushes as you read and on close/suspend; the plugin may prompt about turning
Wi-Fi on at those moments depending on your device.
Two related settings sit under it:
- **Periodically sync every # pages** — how many page turns between updates
(default 50; set 0 to disable periodic updates)
- **Sync mode** — **Immediate** pushes right away, **Checkpoint** batches
changes for slower connections or battery saving
### Sync behavior — who wins when states differ
These two settings decide what the device does when its progress and the
server's disagree, without you opening the Conflicts page:
- **Sync to a newer state** — the server has *newer* progress than this
device (default **Prompt**: the device asks before adopting it; alternatives
are **Silently** or **Never**)
- **Sync to an older state** — this device has *newer* progress than the
server (default **Never**: the device never rolls itself back)
Set both to **Silently** for hands-off syncing, or leave the defaults if you
want a say whenever progress would move.
### What to sync
Seven toggles, all on by default, each controlling one kind of data:
- **Reading progress**
- **Bookmarks**
- **Highlights**
- **Notes**
- **Ratings** — your book ratings made in KOReader
- **Book review** — your review text
- **Reading time** — time-spent statistics
Turn off anything you'd rather keep local; the rest keeps flowing both ways.
## What Syncs ## What Syncs
Once connected, the following sync automatically in both directions between KOReader and Bookhoard (web and other devices): Once connected and enabled, the following sync in both directions between
KOReader and Bookhoard (web, Android, and other devices), each governed by
its toggle above:
- **Reading position** — percentage, chapter, and EPUB CFI where available - **Reading position** — percentage, chapter, and EPUB CFI where available
- **Bookmarks** - **Bookmarks**
- **Highlights** — including highlight colors, mapped between the web and KOReader palettes - **Highlights** — including highlight colors, mapped between the web and
KOReader palettes
- **Notes** — standalone and attached to highlights - **Notes** — standalone and attached to highlights
- **Ratings, review, and reading time** — pushed to the server (push any
time from the menu with **Sync rating, review & time now**)
Books are matched automatically using UUIDs, file hashes (SHA-256, format-aware so converted files still match), file aliases, and title/author fallback. If a book can't be matched, it shows up under the device's **Unlinked Books** in Bookhoard, where you can link it manually. Books are matched automatically using UUIDs, file hashes (SHA-256,
format-aware so converted files still match), file aliases, and
title/author fallback. If a book can't be matched, it shows up under the
device's **Unlinked Books** in Bookhoard, where you can link it manually.
## OPDS Wireless Book Delivery ## OPDS Wireless Book Delivery
Once your device is approved, the plugin also registers Bookhoard's OPDS catalog, so you can browse and download books wirelessly — no USB cable needed. At approval the plugin adds Bookhoard's OPDS catalog to KOReader (and
**Setup OPDS catalog** re-adds it any time), so you can browse and download
books wirelessly — no USB cable needed.
### Browse and Download Books ### Browse and Download Books
@@ -129,7 +229,8 @@ Once your device is approved, the plugin also registers Bookhoard's OPDS catalog
- **PDF**: Fixed-layout documents - **PDF**: Fixed-layout documents
- **CBZ**: Comic book archives - **CBZ**: Comic book archives
Books downloaded via OPDS are automatically matched to your library, so their progress syncs from the first page. Books downloaded via OPDS are automatically matched to your library, so
their progress syncs from the first page.
## Troubleshooting ## Troubleshooting
@@ -142,7 +243,7 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
1. Verify the Server URL is correct (no trailing path — just the base address) 1. Verify the Server URL is correct (no trailing path — just the base address)
2. Make sure KOReader is connected to Wi-Fi 2. Make sure KOReader is connected to Wi-Fi
3. Check the Bookhoard server is reachable from the device's network 3. Check the Bookhoard server is reachable from the device's network
4. Registrations expire after 5 minutes — re-run the sync and approve quickly 4. Registrations expire after 5 minutes — tap **Register device** again and approve quickly
### Connection Refused ### Connection Refused
@@ -161,11 +262,18 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
**Solutions**: **Solutions**:
- Trigger a manual sync from the plugin menu - **Automatically push progress** defaults to off — turn it on in the menu, or push once with **Push progress from this device**
- Check the device shows as enabled on the **Devices** page (open its settings from the icon next to the device) - Check the device shows as enabled on the **Devices** page (open its settings from the icon next to the device)
- Verify the book appears as an unlinked book for the device and link it if needed - Verify the book appears as an unlinked book for the device and link it if needed
- Check Bookhoard server logs for errors - Check Bookhoard server logs for errors
### Turning on auto-sync asks about Wi-Fi
If enabling **Automatically push progress** shows a message about
**"Action when Wi-Fi is off"**, open KOReader's Network settings and set
that action to **turn on** — auto-sync needs permission to bring Wi-Fi up by
itself.
### Conflicts Detected ### Conflicts Detected
**Problem**: Sync conflicts when reading the same book on multiple devices **Problem**: Sync conflicts when reading the same book on multiple devices
@@ -174,7 +282,10 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
1. Open the book's detail page and click **Sync Progress**, or open the Conflicts page (`/conflicts`) 1. Open the book's detail page and click **Sync Progress**, or open the Conflicts page (`/conflicts`)
2. Review the progress reported by each device 2. Review the progress reported by each device
3. Choose which device's progress to keep 3. Choose a resolution: keep one side's progress, keep the server's, or let the furthest progress win
For day-to-day control of the same question on the device itself, use the
**Sync behavior** settings (newer state / older state).
## Security Best Practices ## Security Best Practices
@@ -189,8 +300,3 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271) - [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271)
- [Bookhoard Universal Sync Guide](../sync-guide.md) - [Bookhoard Universal Sync Guide](../sync-guide.md)
- [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin) - [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
---
**Last Updated**: August 2026
**Bookhoard Version**: 1.0
-16
View File
@@ -1,16 +0,0 @@
# Frontend Guide
**Coming Soon**
This guide will cover:
- Navigating the Bookhoard web interface
- Browsing and searching your library
- Managing collections
- Viewing reading progress
- Using notes and highlights
- Personalizing your experience
---
_In the meantime, check out our [Device Setup Guides](devices/)_
+32 -47
View File
@@ -1,58 +1,43 @@
# Saving Custom Filters # Browsing your library
The bookshelf (**All Books**) page lets you save custom filter presets for quick access. Everything starts from the **All Books** page: every book you can see in the
current library, as a wall of covers. From there you narrow, sort, and open.
## Bookshelf Toolbar ## The All Books toolbar
The All Books page has a toolbar with: - The **search box** finds titles, authors, and more as you type
- The **Sort** dropdown orders the wall: **Title (A-Z)**, **Title (Z-A)**,
**Author (A-Z)**, **Date Added**, or **Page Count**
- The **Filters** button opens the filter drawer — authors, tags, series,
progress, and every other field (the full how-to lives in
[Filters — the complete guide](filters/index.md); this page sticks to
browsing)
- **Save**, **Load**, and **Clear** manage your saved filters: capture the
current drawer state under a name, re-apply it any time, and delete ones
you no longer want. Saved filters are private to your account.
- A **search input** for quick text searches Saved filters pair naturally with [smart collections](collections.md): a
- A **sort** dropdown (title, author, date added, page count) filter answers "what matches right now?", a smart collection keeps matching
- A **Filters** button that opens the filter drawer (author, tags, series, and more) forever.
- **Save**, **Load**, and **Clear** buttons for filter presets
## How to Save a Filter ## Opening a book
1. Navigate to the **All Books** page Click any cover to open the book's detail page — description, files, your
2. Click **Filters** to open the drawer, set your desired filters, and click **Apply Filters** progress, actions to read or listen, and the places to edit metadata or fix
3. Click the **Save** button in the toolbar what a scan guessed wrong. The detail page is the hub: series links,
4. Enter a name for your filter (e.g., "My Sci-Fi Books") reviews, bookmarks, and the edit modal all live there.
5. Click **Save**
## Loading Saved Filters ## Series
1. Click the **Load** button to open the **Saved Filters** dropdown A book's detail page links to its **series page** — every book in that
2. Click a filter's name to apply it series, in order, for the same library. Series are one of the fields the
3. The filter values are applied instantly, without a page reload filter drawer can match on too.
## Managing Saved Filters ## Podcasts are separate
**Delete a filter:** Podcast feeds and episodes live on the **Podcasts** page, not the book wall —
see [Podcasts](podcasts.md).
1. Click the **Load** button to open the **Saved Filters** dropdown *Finding your way back:* **All Books** is the wall-of-covers page with the
2. Click the trash icon next to the filter you want to remove **Filters** button; the same toolbar pattern is what you'll see on the
3. Confirm deletion Podcasts page.
**Filter Privacy:**
Saved filters are **private to your account**. Other users cannot see or modify your filters.
## Common Use Cases
**Reading by Genre:**
1. Filter by tag: "Science Fiction"
2. Save as "Sci-Fi Books"
3. Quickly access all your sci-fi collection anytime
**Author Collections:**
1. Filter by author: "Isaac Asimov"
2. Save as "Asimov Books"
3. Switch between different author collections instantly
**Series Tracking:**
1. Filter by series: "Foundation"
2. Save as "Foundation Series"
3. Track your progress through a series
+47
View File
@@ -0,0 +1,47 @@
# Podcasts
Bookhoard is not just books: it also runs your podcast subscriptions. You
subscribe to feeds, episodes download for offline listening, and you can
play a run of episodes back-to-back — a feed, or a hand-picked sequence.
## The Podcasts page
Open **Podcasts** from the sidebar. The page is your subscriptions:
- **Continue** — the episode you were in the middle of, ready to resume
- **All Feeds** — every feed you subscribe to
- **Search** narrows your feeds and episodes
- Each feed's page holds its episodes, with **Download All** for grabbing
everything at once
Subscriptions live in your **Podcasts library** (feeds manage it themselves —
you won't see it in the regular library switcher, and it never mixes into
the book wall on **All Books**).
## Subscribing to a feed
1. Click **Add Feed** on the Podcasts page
2. In the **Add Podcast Feed** panel, paste the show's **RSS feed URL**
3. Choose the **Library** the podcast belongs to
4. Optionally: check **Download all episodes now** to grab the back catalog
immediately, and pick the **Download order** for new episodes — **Feed
order**, **Feed order (reversed)**, **Oldest first (pub date)**, or
**Newest first (pub date)**
5. Click **Add & Subscribe**
From then on Bookhoard watches the feed: new episodes appear as the show
publishes them, downloaded according to the order you picked.
## Playing episodes
Click an episode to open the audio player. The player keeps your place,
supports bookmarks, a chapter list where the show provides chapters, and
playback speed — the same speed and resume-rewind defaults you set on your
[profile](profile-guide.md) apply here.
To play many episodes in a row, use a collection: episode collections have a
**Play as Sequence** button and their own listening order — see
[Collections](collections.md).
*Finding your way back:* **Podcasts** is the sidebar page whose header reads
"Your subscriptions" — the only page with the **Add Feed** button.
+37 -87
View File
@@ -1,99 +1,49 @@
# Profile Management Guide # Your profile
## Updating Your Profile Your profile page holds everything that is about *you* rather than your
books: account details, playback preferences, your password, and — if you
ever need it — account deletion.
Your profile contains your account information and preferences. Open it from the sidebar: click your **username** at the bottom, then
**Profile**.
### How to Update ## Account information
1. Click on your **username** at the bottom of the sidebar to expand the account menu | Field | What it does |
2. Select **Profile** | --- | --- |
3. Edit any fields in the "Account Information" section | **Username** | Your login name — must stay unique |
4. Click **Save Changes** | **Email Address** | Must stay unique |
5. Changes take effect immediately | **First Name / Last Name** | Optional display names |
| **Theme** | A quick theme picker (seven favorites — see below for the full set) |
| **Default Playback Speed** | The speed audiobook playback starts at |
| **Resume Rewind** | Jumps back a few seconds when you resume playback (**Off (exact resume)**, 5, or 10 seconds) |
| **Keep Screen Awake** | Stops the screen sleeping during playback |
| **Timezone** | All times the server shows you (activity, stats, history) are rendered in this zone |
### Fields You Can Update Changes save from the form's save button and apply immediately.
- **Username** - Your login name (must be unique) About the two theme doors: the profile's **Theme** select offers seven
- **Email** - Your email address (must be unique) quick picks. The sidebar's **Appearance** panel offers the full collection —
- **First Name** - Optional display name [Themes and Wood Paneling](themes.md) covers it, including the bookshelf
- **Last Name** - Optional display name wood textures, which are per-browser and not part of your account.
- **Theme** - Your preferred color scheme
## Changing Your Password ## Changing your password
Regular password changes are recommended for account security. On the same page, the **Change Password** section asks for your **Current
Password**, a **New Password**, and **Confirm New Password**. The
requirements are checked as you type; click **Update Password** to save.
### How to Change ## Deleting your account
1. Go to **Profile** page The **Danger Zone** at the bottom holds **Remove My Account**. Deletion
2. Scroll to "Change Password" section removes your account and everything personal to it — profile, reading
3. Enter your **current password** progress and history, device registrations, your collections — and keeps the
4. Enter your **new password** libraries themselves, since books belong to the library, not to you. It
5. **Confirm** your new password cannot be undone.
6. Click **Update Password**
### Password Requirements The last administrator cannot delete their own account — one admin must
always remain.
- Minimum 8 characters Administrators can also edit other users' profiles (including resetting a
- Must contain uppercase, lowercase, number, and special character password without the current one) from the admin area; that is covered with
- Must match confirmation field the rest of the admin docs.
- Current password must be correct
## Deleting Your Account
**⚠️ WARNING:** This action cannot be undone.
### What Gets Deleted
When you delete your account:
- Your profile information
- Reading progress and history
- Device connections
- Collection preferences
- All personal data
### What Stays
- Library books (media belongs to the library, not you)
- System settings
- Other users' accounts
### How to Delete
1. Go to **Profile** page
2. Scroll to "Danger Zone" (bottom of page)
3. Click **Remove My Account**
4. Confirm the deletion prompt
**Note:** If you're the last admin, you cannot delete your account for security reasons.
## Theme Options
Personalize your reading experience with different color themes.
### Quick Theme Switch
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
2. Select a theme from the list; your active theme is marked with a checkmark
3. Changes apply instantly
For the full theme list and bookshelf background (wood) options, see [Themes and Wood Paneling](themes.md).
### Available Themes
- **Tokyo Night** (default) - Blue/purple accents
- **Dracula** - Purple/pink tones
- **Nord** - Arctic, bluish-gray
- **Solarized Dark** - Warm, precise contrast
- **Monokai** - Classic vibrant colors
- **One Dark Pro** - Atom editor inspired
- **Material Dark** - Google Material Design
- **Catppuccin Mocha / Macchiato / Frappé / Latte** - Soothing pastel palettes (Latte is light)
## For Admin Users
If you're an administrator, you can also manage other users' profiles from the admin panel.
See [Admin User Management](admin-guide.md) for details.
+24 -1
View File
@@ -2,7 +2,8 @@
Bookhoard's built-in web reader works in any modern browser — desktop or Bookhoard's built-in web reader works in any modern browser — desktop or
mobile — and keeps your position, highlights, bookmarks, and notes in sync mobile — and keeps your position, highlights, bookmarks, and notes in sync
with the Android app and KOReader. with the Android app and KOReader. Audiobooks get their own player, covered
at the end of this guide.
## Opening a Book ## Opening a Book
@@ -79,3 +80,25 @@ to jump to it.
Position, highlights, bookmarks, and notes sync automatically through the Position, highlights, bookmarks, and notes sync automatically through the
server (see the [Sync Guide](sync-guide.md)). Close the tab whenever you 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. like — the reader resumes exactly where you left off on any device.
## Listening to audiobooks and podcast episodes
Books with audio files, and podcast episodes, open in the audio player
instead of the page reader. It is one player for both:
- The **seek bar** is your position; drag it, or use the **previous /
next episode** controls when you are playing a sequence
- **Bookmarks** opens a panel of your markers for this title — tap one to
jump back
- **Chapters** opens the chapter list (shows "currently chapter N of M");
audiobooks with embedded chapters and podcast shows with chapter marks
both land here
- **Player settings** holds the per-player preferences: **Autoplay on
open**, **Default playback speed**, and **Resume rewind** (a few seconds
back when you resume — the defaults come from your
[profile](profile-guide.md) and this panel overrides them per player)
- The **Shortcuts & interactions** panel (the **?** control) lists every
keyboard shortcut
Progress, bookmarks, and your speed carry across devices through the same
sync as everything else.
-15
View File
@@ -1,15 +0,0 @@
# Settings Guide
**Coming Soon**
This guide will cover:
- Account settings
- Display preferences (themes, fonts)
- Sync configuration
- Device management
- Privacy options
---
_In the meantime, check out our [Device Setup Guides](devices/)_
+99 -555
View File
@@ -1,591 +1,135 @@
# Bookhoard Universal Sync - User Guide # Universal Sync — how your devices stay in step
## Table of Contents Sync is the promise that makes Bookhoard more than a web page: read on
whatever is in your hands, and every other device catches up. One server
holds the truth for each book — your position, bookmarks, highlights, and
notes — and every registered device pushes and pulls against it.
1. [What is Universal Sync?](#what-is-universal-sync) ## Supported devices today
2. [Supported Devices](#supported-devices)
3. [Getting Started](#getting-started)
4. [Device Setup](#device-setup)
5. [Using Sync Features](#using-sync-features)
6. [Book Matching](#book-matching)
7. [Managing Conflicts](#managing-conflicts)
8. [Best Practices](#best-practices)
--- | Platform | Status | How it syncs |
| --- | --- | --- |
| **Web browser** | ✅ Fully supported | Live, as you read — the reader saves position and annotations continuously |
| **Android app** | ✅ Fully supported | The native client: progress, highlights, bookmarks, notes; reads offline and queues changes until you are back online |
| **KOReader** (Kindle, Kobo, PocketBook) | ✅ Fully supported | The Bookhoard KOReader plugin, over Wi-Fi |
| **Kobo, stock firmware** | 🚧 Server support complete | The native Kobo protocol is implemented and documented server-side; device-side enablement is the remaining piece — [Kobo Setup](devices/kobo-setup.md) has the details |
## What is Universal Sync? Kindle and reMarkable are under consideration; KOReader already covers
several of those devices today.
**Universal Sync** is Bookhoard's cross-platform synchronization system that keeps your reading progress, highlights, and notes in sync across all your devices - automatically and in real-time. ## Getting a device into the circle
### Key Features 1. Connect the device to your server (each guide above walks its own path)
2. Approve the pending registration on the **Devices** page — the Android
app registers itself on first login; KOReader asks from its plugin
3. Done. Sync starts as soon as a device is approved
🔄 **Automatic Sync** - Your reading progress syncs automatically when you turn pages The **Devices** page is mission control: see every registered device and
when it was last seen, remove a device (revoking its access instantly), and
work through **unlinked books** — device files that could not be matched to
a library book yet.
📱 **Multi-Platform** - Works with web browsers and KOReader, with native Kobo sync and mobile apps on the roadmap ## Book matching
📍 **Precise Location Tracking** - Supports EPUB CFI, page numbers, percentages, and character offsets When a device reports a book, Bookhoard tries to match it to your library
by fingerprint, strongest first:
⚡ **Real-Time Updates** - See your progress update across all devices instantly 1. **Bookhoard UUID** — 100% confidence
2. **OPF UUID** — 95%
3. **SHA-256 hash** — 90%
4. **OPF identifier** — 85%
5. **ISBN / ASIN** — 80%
6. **Title + Author + file size** — 50%
7. **Title only** — 30% (last resort, only tried when nothing else matched)
🔒 **Secure** - No passwords on devices, web-based authentication only Books that match automatically never bother you. The ones that can't
(side-loaded files, converted formats like EPUB → KEPUB, mismatched
metadata) land in the device's **unlinked books** list, where you can:
📴 **Offline Support** - Queue changes when offline, sync when reconnected - **Auto-link** everything above a confidence threshold
- Review **suggestions** first, then link
- **Link by hand** — pick the right library book yourself
--- ## What syncs
## Supported Devices **Reading progress** — page, percentage, chapter position, EPUB CFI, last
read time. EPUBs sync by percentage and CFI, PDFs and comics by page, so
the same book lines up on devices with different page counts.
### Currently Supported ✅ **Highlights and notes** — the highlighted text, your note, its color, and
where it sits. Locations are stored in several forms at once (page, EPUB
CFI, percentage, character offset), which is why a highlight made on your
phone lands in the right place on an e-reader with different pagination.
| Platform | Status | Sync Method | Notes | **Bookmarks** — location, title, and when you made them.
| ---------------- | ------------------ | --------------------------- | ---------------------------------- |
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
| **KOReader** | ✅ Fully Supported | Wi-Fi (Bookhoard plugin) | Kindle, Kobo, PocketBook hardware |
### Coming Soon 🚧 Everything above is per-user and private: your devices see your data
because you approved them, other users see theirs, and revoking a device
ends its access on the spot.
| Platform | Status | ## Conflicts
| --------------------- | ------------------------------------------------------------- |
| **Kobo Devices** | Native sync coming soon — use KOReader on Kobo hardware today |
| **Mobile Apps** | Android/iOS apps coming later |
### On the Roadmap 🔭 If the same book is read on two devices close together, Bookhoard does not
silently pick a winner — it records a **sync conflict** and shows it to
you:
| Platform | Status | - The book's detail page grows a **Sync Progress** button (with a
| --------------------- | ---------------------------------------- | "Progress conflict detected" notice)
| **Kindle Devices** | Under consideration (no date yet) | - The **Conflicts** page (`/conflicts`) lists everything unresolved
| **Remarkable Tablet** | Under consideration (no date yet) |
--- Resolving means choosing a strategy: keep one side's progress, keep the
server's current value, or let the **furthest progress win**. Until you
resolve, the conflict stays listed — nothing is lost, it just waits for
your call.
## Getting Started ## Offline reading
### Prerequisites Devices keep working without the server. The Android app reads fully
offline and queues your changes, draining the queue when you reconnect.
E-readers push their progress on the next connection. The server holds a
per-device queue for inbound work and retries failures automatically; if
items pile up (a device offline for weeks), an admin can retry or clear a
device's queue from the server side.
1. **Bookhoard Server** - Self-hosted instance running and accessible ## Reading history
2. **Network Connection** - Devices must be able to reach your Bookhoard server
3. **User Account** - Created and logged in to Bookhoard web interface
### Quick Start Every reading session is logged — when, how long, on which device — and
feeds your [dashboard](dashboard.md) and stats. Your history is visible on
1. **Set up your first device** - See [Device Setup](#device-setup) below the **Progress** page and on each book's detail page, and it is yours
2. **Start reading** - Open any book in the web interface or on your device alone.
3. **Turn pages** - Progress syncs automatically
4. **Switch devices** - Pick up any other device - your progress is there!
---
## Device Setup
For detailed device configuration instructions, see the appropriate setup guide:
- **[KOReader Setup Guide](devices/koreader-setup.md)** - KOReader configuration (Kindle, Kobo, and PocketBook hardware)
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon; use KOReader today)
### Quick Overview
**Registration Process** (KOReader):
1. Install the Bookhoard plugin and enter your server URL in KOReader
2. Approve the pending registration on the Bookhoard **Devices** page (sidebar navigation)
3. That's it — sync starts automatically once approved
**Device Management**:
```
Devices page (sidebar navigation)
```
You can:
- View all your registered devices
- See last sync time and status
- Disable or remove devices
- View unlinked books that need matching
- Manage file aliases
---
## Book Matching
### What is Book Matching?
When devices sync books, Bookhoard tries to automatically match them using:
- **SHA-256 hash** (most reliable) - Content-based fingerprint
- **ISBN** - Standard book identifier
- **UUID** - Unique identifier from EPUB metadata
- **Title + Author** (fallback) - Last resort matching
### Unlinked Books
Sometimes a book on your device can't be automatically matched to your library. This happens when:
- The book was side-loaded (not downloaded via Bookhoard)
- The file format was converted (EPUB → KEPUB)
- The metadata doesn't match exactly
### Viewing Unlinked Books
```
Devices page → select device → unlinked books
```
### Resolving Unlinked Books
1. **Auto-Link** - Automatically link books with 80%+ confidence
2. **Get Suggestions** - Review potential matches before linking
3. **Manual Link** - Choose the correct match yourself
### Book Matching Priority
1. **Bookhoard UUID** - 100% confidence
2. **OPF UUID** - 95% confidence
3. **SHA-256 hash** - 90% confidence
4. **OPF identifier** - 85% confidence
5. **ISBN/ASIN** - 80% confidence
6. **Title + Author + File Size** - 50% confidence
---
## Managing Conflicts
### Reading Progress
**What Syncs**:
- Current page number
- Reading percentage
- Chapter progress
- EPUB CFI (for EPUB files)
- Last read timestamp
- Reading position (viewport, zoom, scroll)
**How It Works**:
```
You turn page → Device sends progress → Server updates database
↓
Broadcasts to all your devices
↓
Other devices update their display
```
**Supported Progress Types**:
- **EPUB/MOBI**: Percentage + EPUB CFI + Chapter
- **PDF/DJVU**: Page number + Viewport position
- **CBZ/CBR**: Page number + Panel coordinates
### Highlights & Notes
**What Syncs**:
- Highlighted text
- Notes and annotations
- Bookmark locations
- Colors and formatting
- Chapter/paragraph references
**Universal Location References**:
All highlights are stored with multiple location types:
- Page:offset (traditional)
- EPUB CFI (EPUB files)
- Percentage (0-100%)
- Character offset (exact position)
- Chapter references
This ensures your highlights work across all devices, even with different page counts!
### Bookmarks
**What Syncs**:
- Bookmark locations
- Bookmark titles
- Date created
- Reading position context
---
## Sync Modes
### Immediate Mode (Default)
**Best For**: Normal reading, page turns
**Behavior**:
- Syncs every page turn
- Real-time updates
- Low latency
- Higher bandwidth usage
**Recommended Settings**:
- Auto-sync: ON
- Sync frequency: Every page turn
### Checkpoint Mode
**Best For**: Slow connections, battery saving
**Behavior**:
- Batches changes
- Syncs every 5 minutes or when connection allows
- Lower bandwidth
- Better for offline reading
**Recommended Settings**:
- Auto-sync: ON
- Sync frequency: Checkpoint mode
---
## Offline Support
### How Offline Sync Works
1. **Offline Reading**:
- Read normally without connection
- All progress tracked locally
2. **Queue Changes**:
- Progress changes queued automatically
- Annotations saved locally
- Up to 100 items in offline queue
3. **Reconnection**:
- Connect to Wi-Fi
- Queue processes automatically
- Priority items first (book completion, notes)
- All devices updated
### Offline Indicators
**In Web Interface**:
- Yellow status icon: Device offline
- Last seen timestamp
- "Pending sync" badge on books
**On Devices**:
- Sync icon: Gray = offline
- Sync icon: Blue = syncing
- Sync icon: Green = synced
---
## Troubleshooting ## Troubleshooting
### Sync Not Working **Progress not updating**
Check the device is still on the **Devices** page and was approved; check
the server URL in the device's settings; confirm the device can reach the
server (open the server URL in the device's browser as a test).
**Symptoms**: Progress not updating across devices **"Device not found"**
The device was removed or its token revoked — register it again and
approve it.
**Solutions**: **A conflict keeps coming back**
Two devices are genuinely fighting over the same book. Decide which device
is the reader for that book, resolve with a strategy above, and avoid
simultaneous reading of the same title.
1. Check device is online: Devices page (sidebar navigation) **High battery or data use**
2. Verify sync is enabled for the device That is the cost of continuous sync on a chatty device; e-reader plugins
3. Check sync URL is correct let you space out their sync checks from the plugin's own settings.
4. Ensure device has network connection
5. Try manual sync: Open book → Menu → Sync Now
### "Device Not Found" Error ## Device limits
**Cause**: Device not registered or authorization revoked Each account has a device limit — **10 by default**, and administrators can
raise or lower it per user. Removing a device you no longer use frees a
**Solutions**: slot.
1. Re-register the device
2. Check device hasn't been removed
3. Verify correct device type selected
### "Rate Limit Exceeded" Error
**Cause**: Too many sync requests
**Solutions**:
1. Wait a few seconds
2. Switch to checkpoint mode
3. Contact admin to increase limits
### Conflicts Detected
**Symptoms**: "Sync conflict" notification
**Cause**: Same book being read on multiple devices simultaneously
**Solutions**:
1. Open the book's detail page and click **Sync Progress**, or go to the Conflicts page (`/conflicts`)
2. Review both device progress
3. Choose which device's progress to keep
4. Or choose "Merge" (keeps furthest progress)
### High Battery Usage
**Cause**: Immediate sync mode with frequent page turns
**Solutions**:
1. Switch to checkpoint mode
2. Increase sync interval
3. Sync less frequently
--- ---
## Best Practices More detail on any device lives in its guide:
[Android](devices/android-app.md) ·
### For Optimal Performance [KOReader](devices/koreader-setup.md) ·
[Kobo](devices/kobo-setup.md) ·
✅ **DO**: [Calibre import](calibre-integration.md) ·
and the [filter rules](filters/index.md) that power matching-adjacent
- Use checkpoint mode when on slow connections shelves like [smart collections](collections.md).
- Keep device firmware updated
- Use Wi-Fi when available
- Approve only devices you own
- Regularly check conflict resolution
❌ **DON'T**:
- Read same book on multiple devices simultaneously
- Ignore conflict notifications
- Register public/shared devices
- Exceed device limits (max 10 per user)
### Organizing Your Library
**For Best Sync Experience**:
- Use consistent metadata (titles, authors)
- Avoid duplicate books in library
- Match files by ISBN when possible
- Use clean file structure
### Managing Multiple Devices
**Recommended Setup**:
- **Primary Device**: KOReader on e-reader
- **Secondary Device**: Web browser (work/home)
- **On the go**: Web browser on a phone (dedicated mobile apps coming later)
**Sync Strategy**:
1. Read mainly on primary device
2. Check progress on web/secondary devices
3. Let auto-sync handle updates
4. Resolve conflicts promptly
---
## Advanced Features
### Conflict Resolution
**Automatic Resolution**:
- Most recent progress wins
- Timestamp-based comparison
- 5-minute window for conflict detection
**Manual Resolution**:
```
Book detail → Sync Progress → choose winner
```
**Options**:
- **Keep Device A**: Use this device's progress
- **Keep Device B**: Use other device's progress
- **Merge**: Keep furthest progress (combination)
- **Auto-Resolve Future**: Always prefer this device
### Sync Queue Management
**View Queue Status**:
```
Devices page → sync queue section
```
**Queue Stats**:
- Pending: Waiting to sync
- Processing: Currently syncing
- Failed: Retry scheduled
- Completed: Successfully synced
**Manual Actions**:
- **Retry All**: Retry all failed items
- **Clear Queue**: Remove all pending items
- **Priority Sync**: Sync specific book immediately
### Reading History
**Automatic Tracking**:
- Every sync session logged
- Time spent reading calculated
- Pages read tracked
- Device used recorded
**View History**:
```
Progress page (sidebar navigation), or the book's detail page
```
**Privacy**:
- Only you can see your history
- History kept for 365 days
- Exportable for backup
---
## Security & Privacy
### Device Authentication
**Secure by Design**:
- ✅ No passwords stored on devices
- ✅ Web-based approval required
- ✅ Unique tokens per device
- ✅ Revocable at any time
- ✅ Token encryption in transit
### Data Protection
**What We Store**:
- Reading progress (page, percentage)
- Highlights and notes
- Device identifiers
- Sync timestamps
**What We DON'T Store**:
- Passwords on devices
- Reading content (your books)
- Unencrypted personal data
- Location data (GPS)
### Access Control
**Your Data**:
- Only you can see your progress
- Admins cannot read your annotations
- Shared only with devices you approve
**Device Access**:
- Each device sees only your libraries
- Devices cannot access other users
- Revoking removes all access
---
## Performance Tips
### For Faster Sync
1. **Use 5GHz Wi-Fi** - Faster than 2.4GHz
2. **Keep server nearby** - Low latency = better sync
3. **Regular updates** - Keeps firmware current
4. **Checkpoint mode** - For batch processing
### For Better Battery Life
1. **Checkpoint mode** - Fewer sync requests
2. **Increase sync interval** - Fewer updates
3. **Close when not reading** - Reduces background activity
---
## FAQ
### General Questions
**Q: Does sync work with all book formats?**
A: Yes! EPUB, PDF, MOBI, CBZ, CBR and more.
**Q: Can multiple users share a device?**
A: No, devices are tied to individual accounts for security.
**Q: What happens if I delete a book?**
A: All sync data for that book is removed from the server.
**Q: Can I export my reading data?**
A: Reading data isn't exportable from the UI yet — it's accessible via the API.
**Q: Does sync work over the internet?**
A: Yes, if your server is publicly accessible with HTTPS.
**Q: How much data does sync use?**
A: Approximately 1KB per page turn, 50KB per annotation.
### Technical Questions
**Q: How does sync handle different page counts?**
A: Uses percentage and EPUB CFI for universal positioning.
**Q: Can I sync with Calibre anymore?**
A: Bookhoard's KOReader sync uses a dedicated plugin (server-side approval, device tokens) — no Calibre involvement required.
**Q: What if I lose my device?**
A: Revoke it in settings and register a new one.
**Q: Is sync end-to-end encrypted?**
A: Yes, HTTPS/TLS 1.3 for all sync traffic.
---
## Getting Help
### Documentation
- **API Docs**: `/docs/api.md`
- **Device Guides**: `/docs/devices/`
- **Self-Hosting**: `/docs/install.md`
### Support
- **Issues**: Report bugs at GitHub Issues
- **Discussions**: Join GitHub Discussions
- **Email**: support@bookhoard.example.com
### Community
- **Forum**: community.bookhoard.example.com
- **Matrix**: #bookhoard:matrix.org
- **Discord**: discord.gg/bookhoard
---
## Changelog
### Version 1.0.x (2026)
- ✅ Web sync support
- ✅ KOReader sync (progress, bookmarks, highlights, notes)
- ✅ Conflict resolution
- ✅ Offline queue
- ✅ Real-time WebSocket sync
- 🚧 Native Kobo sync (coming soon)
- 🚧 Mobile apps (coming later)
---
**Last Updated**: August 2026
**Version**: 1.0
**License**: AGPL-3.0
+8 -2
View File
@@ -34,7 +34,7 @@ full set of [Omarchy](https://omarchy.org) defaults alongside the classics:
**Light** **Light**
- **Lupine** - Clean light with violet-blue accents (default light theme) - **Lupine** - Clean light with violet-blue accents
- **Tokyo Night Day** - Daylight counterpart of the default - **Tokyo Night Day** - Daylight counterpart of the default
- **Solarized Light** - Precision contrast on warm paper - **Solarized Light** - Precision contrast on warm paper
- **One Light** - Atom's light theme - **One Light** - Atom's light theme
@@ -52,11 +52,17 @@ full set of [Omarchy](https://omarchy.org) defaults alongside the classics:
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom) 1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
2. Use the **Dark / Light** switch at the top to show the half you want — 2. Use the **Dark / Light** switch at the top to show the half you want —
it opens on the half your current theme lives in it opens on the half your current theme lives in, and each side shows
how many themes it holds. The switch only filters the list; it never
changes your theme by itself
3. Pick a theme from the list — each option shows its color swatch, and your 3. Pick a theme from the list — each option shows its color swatch, and your
active theme is marked with a checkmark active theme is marked with a checkmark
4. Your choice is saved automatically and synced across devices 4. Your choice is saved automatically and synced across devices
There is exactly one default: **Tokyo Night** on a fresh install. Nothing
else is pre-selected for you — pick whichever theme you like from either
half.
On small screens, open the sidebar with the menu button in the top bar first. On small screens, open the sidebar with the menu button in the top bar first.
## Wood Paneling ## Wood Paneling
-15
View File
@@ -1,15 +0,0 @@
# User Areas Guide
**Coming Soon**
This guide will cover:
- Managing your personal library
- Uploading and organizing books
- Creating and managing collections
- Syncing across devices
- Personal settings
---
_In the meantime, check out our [Sync Guide](sync-guide.md)_
+38 -70
View File
@@ -1,79 +1,47 @@
# User Documentation # Bookhoard — start here
Welcome to the Bookhoard user documentation. This section contains guides for using Bookhoard features, setting up devices, and configuring sync. Bookhoard is a self-hosted home for the things you read and hear: your book
libraries live on your own server, you read them in the browser or on your
devices, and everything keeps track of where you are.
## 📱 Device Setup The moving parts, in one breath:
Learn how to configure your e-reader devices to sync with Bookhoard: - **Libraries** hold your books (and your podcasts run as feeds of their own)
- The **dashboard** is your home screen — shelves that update themselves
- **Collections** are shelves you build, by hand or by rules
- **Filters** answer "which books match?" — the same rules power saved
filters, smart collections, and dashboard shelves
- **Devices** — the Android app, KOReader on e-readers, and stock Kobo
firmware — stay in step with the server through **sync**
- Your **profile** holds your preferences: theme, playback defaults, password
- **[Android App Guide](devices/android-app.md)** - The native Android client ## Where to go
- Installing and upgrading the APK
- Connecting to a self-hosted server over LAN
- The Android 16+ local-network permission
- Troubleshooting login failures
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader | You want to... | Read |
- Installation on Kindle/Kobo/PocketBook | --- | --- |
- Plugin setup with server-side device approval | Learn the layout: dashboard, shelves, customizing | [Dashboard](dashboard.md) |
- Progress, bookmark, highlight, and note sync | Browse, search, and sort your books | [Browsing your library](library-browsing.md) |
- OPDS catalog access | Build shelves — by hand or with rules | [Collections](collections.md) |
- Troubleshooting | Narrow with rules, save them, reuse them | [Filters — the complete guide](filters/index.md) |
| Subscribe to shows, download, listen | [Podcasts](podcasts.md) |
| Read and annotate in the browser | [Web Reader Guide](reader-guide.md) |
| Change themes and bookshelf textures | [Themes and Wood Paneling](themes.md) |
| Edit your account, password, playback defaults | [Your profile](profile-guide.md) |
| Get the Android app talking to your server | [Android App](devices/android-app.md) |
| Use KOReader on an e-reader | [KOReader Setup](devices/koreader-setup.md) |
| Use a Kobo | [Kobo Setup](devices/kobo-setup.md) |
| Understand how it all stays in sync | [Universal Sync Guide](sync-guide.md) |
| Import from Calibre | [Calibre Integration](calibre-integration.md) |
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon) New here? The usual path is: browse [your library](library-browsing.md),
- In the meantime, KOReader works great on Kobo hardware open a book, and let the [dashboard](dashboard.md) keep track of you. Sync
comes when you add a [device](devices/android-app.md).
## 🔄 Sync Configuration ## A note on admins
- **[Universal Sync Guide](sync-guide.md)** - Understanding and using sync features Some screens (user management, server settings, library folders) are admin
- What is Universal Sync? territory; [Admin Guide](admin-guide.md) is the door, and the admin docs are
- Supported devices being expanded separately.
- Book matching and auto-linking
- Conflict resolution
- Best practices
## 📚 Calibre Integration **Looking for developer documentation?** See the
[Developer Portal](../developer/development.md).
- **[Calibre Integration Guide](calibre-integration.md)** - Integrating with Calibre libraries
- What is Calibre?
- Automatic metadata import
- Setting up your Calibre library
- Supported metadata and file types
- Workflow examples
- Troubleshooting
- 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.md)** - Learn how to use the Bookhoard web interface
- _Coming Soon_
## 👤 User Areas
**[User Areas Guide](user-areas.md)** - Managing your personal library and settings
- _Coming Soon_
## ⚙️ Settings
**[Settings Guide](settings-guide.md)** - Configuring your Bookhoard preferences
- _Coming Soon_
## 🔐 Admin Features
**[Admin Guide](admin-guide.md)** - Administrative functions and management
- _Coming Soon_
---
**Looking for developer documentation?** See the [Developer Portal](../developer/development.md)
+29 -14
View File
@@ -8,40 +8,55 @@ import (
"testing" "testing"
) )
// TestDocsRelativeLinks keeps the developer docs tree internally navigable: // TestDocsRelativeLinks keeps every doc tree internally navigable: every
// every relative markdown link must point at a file that exists. The // relative markdown link must point at a file that exists. The api-reference
// api-reference overview once shipped 20+ directory-style links // overview once shipped 20+ directory-style links (authentication/ etc.)
// (authentication/ etc.) that 404'd — this gate makes that class of rot // that 404'd — this gate makes that class of rot fail make test.
// fail make test. (docs/user is excluded: end-user prose has its own pass.)
// //
// Rules: // Rules:
// - http(s):, mailto:, and pure #anchors are skipped // - http(s):, mailto:, and pure #anchors are skipped
// - every other target must end in .md and resolve (relative to the // - every other target must end in .md and resolve (relative to the
// linking file) to an existing file inside the tree // linking file) to an existing file inside the docs tree
// - directory-style targets ("area/") are the exact bug this test exists // - directory-style targets ("area/") are the exact bug this test exists
// for and always fail // for and always fail
//
// Hands-off sources (project owner instruction, 2026-11-02: DO NOT EDIT THE
// FILTER DOCS): docs/user/filters/** and docs/user/searching-and-filtering.md
// are not scanned as link sources, so no future gate failure can force an
// edit there. Files linking INTO them are still checked — resolution needs
// file existence only, never edits to their content.
var docsLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)\s]+)\)`) var docsLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)\s]+)\)`)
func TestDocsRelativeLinks(t *testing.T) { var handsOffFilterDocs = []string{
// Walk developer docs; escape-check against the whole docs tree — "user/filters/",
// cross-area links into docs/user are legitimate (same site). "user/searching-and-filtering.md",
devRoot, err := filepath.Abs("../../docs/developer")
if err != nil {
t.Fatalf("abs developer root: %v", err)
} }
func TestDocsRelativeLinks(t *testing.T) {
treeRoot, err := filepath.Abs("../../docs") treeRoot, err := filepath.Abs("../../docs")
if err != nil { if err != nil {
t.Fatalf("abs docs root: %v", err) t.Fatalf("abs docs root: %v", err)
} }
err = filepath.WalkDir(devRoot, func(path string, d os.DirEntry, err error) error { err = filepath.WalkDir(treeRoot, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") { if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
return err return err
} }
rel, err := filepath.Rel(treeRoot, path)
if err != nil {
return err
}
relSlash := filepath.ToSlash(rel)
for _, off := range handsOffFilterDocs {
if strings.HasPrefix(relSlash, off) {
return nil
}
}
raw, err := os.ReadFile(path) raw, err := os.ReadFile(path)
if err != nil { if err != nil {
return err return err
} }
rel, _ := filepath.Rel(docsRoot, path)
absPath, err := filepath.Abs(path) absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
return err return err
+215
View File
@@ -0,0 +1,215 @@
package docs
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestUIControlLabels holds the UI documentation to the source of the UI.
// The glossary (docs/developer/ui-controls.md) is the canonical spelling of
// every control the docs cite; this test greps that spelling against the
// server-rendered templates, so a renamed button fails make test in the
// same commit as the rename — the class of rot screenshots could never be
// gated. Dynamic (Alpine-composed) labels are OR-checked with their `also`
// fragments instead of requiring the composed string to exist.
//
// Scope is substring-level (not word-boundary): the point is catching
// renames and deletions, not stopping coincidental substrings. New controls
// cited in prose get a glossary entry in the same commit (writing-ui-docs.md
// is the standard; the filter docs are the hands-off exemplar).
const (
labelGlossaryRel = "../../docs/developer/ui-controls.md"
labelTemplRootRel = "../../templates"
labelTSRootRel = "../../web/src"
)
type docGlossaryEntry struct {
label string
where string
does string
source string
reason string
also []string
skip bool
}
// docCorpus concatenates every file under root ending in suffix.
func docCorpus(t *testing.T, root string, suffix string) string {
t.Helper()
abs, err := filepath.Abs(root)
if err != nil {
t.Fatalf("abs %s: %v", root, err)
}
var b strings.Builder
err = filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, suffix) {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
b.Write(raw)
b.WriteString("\n")
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
return b.String()
}
func corpusName(source string) string {
if source == "ts" {
return "web/src"
}
return "templ"
}
func parseUIControlsBlock(t *testing.T, raw string) []docGlossaryEntry {
t.Helper()
var block strings.Builder
inBlock, foundAny := false, false
for _, line := range strings.Split(raw, "\n") {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "```controls"):
inBlock, foundAny = true, true
continue
case trimmed == "```" && inBlock:
inBlock = false
continue
}
if inBlock {
block.WriteString(line)
block.WriteString("\n")
}
}
if !foundAny {
t.Fatalf("no ```controls block found in %s", labelGlossaryRel)
}
var entries []docGlossaryEntry
haveCur := false
cur := docGlossaryEntry{}
flush := func() {
if haveCur {
entries = append(entries, cur)
haveCur = false
}
}
for _, line := range strings.Split(block.String(), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, ":")
if !ok {
t.Fatalf("malformed controls line %q (want `key: value`)", line)
}
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
switch key {
case "label":
flush()
haveCur = true
cur = docGlossaryEntry{label: val}
case "where":
cur.where = val
case "does":
cur.does = val
case "source":
cur.source = val
case "also":
for _, frag := range strings.Split(val, "|") {
if frag = strings.TrimSpace(frag); frag != "" {
cur.also = append(cur.also, frag)
}
}
case "skip":
cur.skip = val == "true"
case "reason":
cur.reason = val
case "note":
// free text for humans
default:
t.Fatalf("unknown field in controls block: %q (allowed: label|where|does|source|also|skip|reason|note)", key)
}
}
flush()
return entries
}
func TestUIControlLabels(t *testing.T) {
raw, err := os.ReadFile(labelGlossaryRel)
if err != nil {
t.Fatalf("read glossary: %v", err)
}
entries := parseUIControlsBlock(t, string(raw))
if len(entries) == 0 {
t.Fatalf("glossary has no entries — add at least one before citing controls")
}
tmplRequired, tsRequired := false, false
for _, e := range entries {
if e.source == "ts" {
tsRequired = true
} else {
tmplRequired = true
}
}
corpora := map[string]string{}
if tmplRequired {
corpora[corpusName("")] = docCorpus(t, labelTemplRootRel, ".templ")
}
if tsRequired {
corpora[corpusName("ts")] = docCorpus(t, labelTSRootRel, ".ts")
}
seen := map[string]int{}
for _, e := range entries {
if e.label == "" {
t.Errorf("entry with empty label (where: %q, does: %q)", e.where, e.does)
continue
}
seen[e.label]++
if e.where == "" || e.does == "" {
t.Errorf("control %q: `where` and `does` are required", e.label)
}
if e.skip && e.reason == "" {
t.Errorf("control %q: skip=true requires a reason", e.label)
}
switch e.source {
case "", "templ", "ts":
default:
t.Errorf("control %q: unknown source %q (templ|ts)", e.label, e.source)
}
if e.skip {
continue
}
fragments := append([]string{e.label}, e.also...)
found := false
for _, frag := range fragments {
if strings.Contains(corpora[corpusName(e.source)], frag) {
found = true
break
}
}
if !found {
t.Errorf("control %q (%s): none of the fragments [%s] found in %s sources — the label was renamed or the glossary is stale. Update ui-controls.md in the same commit as the rename.",
e.label, e.where, strings.Join(fragments, " | "), corpusName(e.source))
}
}
for label, n := range seen {
if n > 1 {
t.Errorf("control %q listed %d times — one entry per control", label, n)
}
}
}
+4 -2
View File
@@ -124,8 +124,10 @@ type UserProfile struct {
Role string `json:"role"` Role string `json:"role"`
Theme string `json:"theme,omitempty"` Theme string `json:"theme,omitempty"`
// User-level default audiobook playback speed (plan §8); 0 omits. // User-level default audiobook playback speed (plan §8); 0 omits.
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"` // Response-only struct: omit nothing — default_audio_speed is NOT NULL
// Whether the web reader/player should hold a Screen Wake Lock. // DEFAULT 1.0 (writes validate 0.25–4), so a real profile always emits
// it; omitempty here would drop a contract field from the docs gate.
DefaultAudioSpeed float64 `json:"default_audio_speed"` // Whether the web reader/player should hold a Screen Wake Lock.
KeepScreenAwake bool `json:"keep_screen_awake"` KeepScreenAwake bool `json:"keep_screen_awake"`
// Resume rewind: seconds before the saved position the audio // Resume rewind: seconds before the saved position the audio
// player starts (open-resume and every genuine unpause). 0 is a // player starts (open-resume and every genuine unpause). 0 is a
+5 -2
View File
@@ -41,10 +41,13 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
// the component and asserts the hash matches. // the component and asserts the hash matches.
// //
// style-src keeps 'unsafe-inline' for htmx-injected styles and // style-src keeps 'unsafe-inline' for htmx-injected styles and
// Alpine :style attribute writes. // Alpine :style attribute writes, plus blob: for the
// reader-generated stylesheets foliate injects at open time
// (blocked since the CSP migration — every /readers load
// logged a violation; selection/highlight styling rode on it).
// font/img blob: — the reader renders EPUB-embedded fonts and // font/img blob: — the reader renders EPUB-embedded fonts and
// images from blob: URLs inside the foliate content frame. // images from blob: URLs inside the foliate content frame.
c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' "+ScriptHashThemeFlashGuard+"; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:") c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' "+ScriptHashThemeFlashGuard+"; style-src 'self' 'unsafe-inline' blob:; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:")
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
+1 -1
View File
@@ -66,7 +66,7 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
if item.Author != "" { if item.Author != "" {
<p style="color: var(--text-secondary)">{ item.Author }</p> <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> <p class="font-mono text-xs break-all line-clamp-3" style="color: var(--text-secondary)">{ item.FilePath }</p>
</td> </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-primary)">{ item.LibraryName }</td>
<td class="px-4 py-3" style="color: var(--text-secondary)">{ item.Status }</td> <td class="px-4 py-3" style="color: var(--text-secondary)">{ item.Status }</td>
+2 -2
View File
@@ -164,14 +164,14 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"font-mono text-xs break-all\" style=\"color: var(--text-secondary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"font-mono text-xs break-all line-clamp-3\" style=\"color: var(--text-secondary)\">")
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_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath) templ_7745c5c3_Var7, 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: `admin_archived.templ`, Line: 69, Col: 103} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 69, Col: 116}
} }
_, 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 {
+10 -5
View File
@@ -49,7 +49,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
unless purged; items return automatically if their files come back. unless purged; items return automatically if their files come back.
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<a href="/admin/archived" class="btn btn-secondary">View Archived</a> <a href="/admin/archived" class="btn btn-secondary">View Archived</a>
<button type="button" @click="purgeArchivedItems()" class="btn btn-secondary"> <button type="button" @click="purgeArchivedItems()" class="btn btn-secondary">
@Icon("trash", "h-4 w-4") @Icon("trash", "h-4 w-4")
@@ -200,8 +200,13 @@ templ LibraryList(user User, libraries []LibraryData, users []User) {
<div class="space-y-4"> <div class="space-y-4">
for _, library := range libraries { for _, library := range libraries {
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<div class="flex items-center justify-between gap-4 p-5"> <div class="flex flex-wrap items-center justify-between gap-x-6 gap-y-3 p-5">
<div class="flex items-center gap-3 min-w-0 flex-1"> <!-- No flex-1 here: a 0 basis item never triggers the row's
flex-wrap, so its shrink-0 chips overprinted the Scan/
Manage buttons on phones. Content-sized block + min-w-0
truncates via the h3; justify-between still splits the
columns on desktop. -->
<div class="flex items-center gap-3 min-w-0">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);"> <span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("library", "h-5 w-5") @Icon("library", "h-5 w-5")
</span> </span>
@@ -211,12 +216,12 @@ templ LibraryList(user User, libraries []LibraryData, users []User) {
<p class="text-sm truncate" style="color: var(--text-secondary)">{ library.Description }</p> <p class="text-sm truncate" style="color: var(--text-secondary)">{ library.Description }</p>
} }
</div> </div>
<span class="chip shrink-0"> <span class="chip shrink-0 hidden sm:inline-flex">
@Icon("tag", "h-3 w-3") @Icon("tag", "h-3 w-3")
{ library.TypeName } { library.TypeName }
</span> </span>
if library.FolderCount > 0 { if library.FolderCount > 0 {
<span class="chip shrink-0"> <span class="chip shrink-0 hidden sm:inline-flex">
@Icon("folder", "h-3 w-3") @Icon("folder", "h-3 w-3")
{ library.FolderCount } folders { library.FolderCount } folders
</span> </span>
+40 -40
View File
@@ -95,7 +95,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
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, "</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\" @click=\"purgeArchivedItems()\" class=\"btn btn-secondary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</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 flex-wrap items-center gap-2\"><a href=\"/admin/archived\" class=\"btn btn-secondary\">View Archived</a> <button type=\"button\" @click=\"purgeArchivedItems()\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -288,7 +288,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, library := range libraries { for _, library := range libraries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"card overflow-hidden\"><div class=\"flex items-center justify-between gap-4 p-5\"><div class=\"flex items-center gap-3 min-w-0 flex-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"card overflow-hidden\"><div class=\"flex flex-wrap items-center justify-between gap-x-6 gap-y-3 p-5\"><!-- No flex-1 here: a 0 basis item never triggers the row's\n\t\t\t\t\t\t flex-wrap, so its shrink-0 chips overprinted the Scan/\n\t\t\t\t\t\t Manage buttons on phones. Content-sized block + min-w-0\n\t\t\t\t\t\t truncates via the h3; justify-between still splits the\n\t\t\t\t\t\t columns on desktop. --><div class=\"flex items-center gap-3 min-w-0\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -303,7 +303,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 209, Col: 92} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 214, Col: 92}
} }
_, 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 {
@@ -321,7 +321,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 211, Col: 95} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 216, Col: 95}
} }
_, 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 {
@@ -332,7 +332,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><span class=\"chip shrink-0\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><span class=\"chip shrink-0 hidden sm:inline-flex\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -343,7 +343,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 216, Col: 26} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 221, Col: 26}
} }
_, 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 {
@@ -354,7 +354,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if library.FolderCount > 0 { if library.FolderCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"chip shrink-0\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"chip shrink-0 hidden sm:inline-flex\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -365,7 +365,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 221, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 226, Col: 30}
} }
_, 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 {
@@ -383,7 +383,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan") templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 227, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 232, Col: 58}
} }
_, 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 {
@@ -404,7 +404,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 237, Col: 32} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 242, Col: 32}
} }
_, 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 {
@@ -425,7 +425,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 246, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 251, Col: 44}
} }
_, 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 {
@@ -500,7 +500,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 268, Col: 99} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 273, Col: 99}
} }
_, 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 {
@@ -513,7 +513,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/scan") templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/scan")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 271, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 276, Col: 57}
} }
_, 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 {
@@ -526,7 +526,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderScan(folder.FolderPath)) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderScan(folder.FolderPath))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 272, Col: 53} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 277, Col: 53}
} }
_, 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 {
@@ -547,7 +547,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders") templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 283, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 288, Col: 62}
} }
_, 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 {
@@ -560,7 +560,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath)) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 284, Col: 55} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 289, Col: 55}
} }
_, 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 {
@@ -573,7 +573,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 285, Col: 49} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 290, Col: 49}
} }
_, 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 {
@@ -604,7 +604,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders") templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 295, Col: 80} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 300, Col: 80}
} }
_, 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 {
@@ -617,7 +617,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 295, Col: 124} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 300, Col: 124}
} }
_, 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 {
@@ -630,7 +630,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 299, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 304, Col: 37}
} }
_, 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 {
@@ -643,7 +643,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var26 string var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 306, Col: 32} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 311, Col: 32}
} }
_, 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 {
@@ -656,7 +656,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var27 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 307, Col: 52} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 312, Col: 52}
} }
_, 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 {
@@ -713,7 +713,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var28 string var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility") templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 336, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 341, Col: 63}
} }
_, 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 {
@@ -726,7 +726,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var29 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`) templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 337, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 342, Col: 47}
} }
_, 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 {
@@ -739,7 +739,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var30 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID) templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 339, Col: 49} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 344, Col: 49}
} }
_, 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 {
@@ -752,7 +752,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var31 string var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 342, Col: 76} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 347, Col: 76}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -765,7 +765,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var32 string var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email) templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 343, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 348, Col: 75}
} }
_, 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 {
@@ -793,7 +793,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var33 templ.SafeURL var templ_7745c5c3_Var33 templ.SafeURL
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues") templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 352, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 357, Col: 57}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -820,7 +820,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var34 string var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount) templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 357, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 362, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -847,7 +847,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var35 string var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 366, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 371, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -860,7 +860,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var36 string var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name) templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 367, Col: 33} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 372, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -873,7 +873,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var37 string var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description) templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 368, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 373, Col: 40}
} }
_, 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 {
@@ -894,7 +894,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var38 string var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 376, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 381, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -907,7 +907,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var39 string var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name) templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 377, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 382, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -965,7 +965,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var41 string var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath) templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 392, Col: 89} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 397, Col: 89}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -983,7 +983,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var42 string var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID) templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 398, Col: 117} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 403, Col: 117}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1014,7 +1014,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var43 string var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID) templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 411, Col: 118} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 416, Col: 118}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1035,7 +1035,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var44 string var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name) templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 416, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 421, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1053,7 +1053,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var45 string var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput) templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 423, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 428, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1066,7 +1066,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var46 string var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath) templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 424, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 429, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+1 -1
View File
@@ -48,7 +48,7 @@ templ AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, tot
if len(feeds) == 0 { if len(feeds) == 0 {
<div class="card p-6 text-center text-sm" style="color: var(--text-secondary)">No podcast feeds exist yet.</div> <div class="card p-6 text-center text-sm" style="color: var(--text-secondary)">No podcast feeds exist yet.</div>
} else { } else {
<div class="oc-table card"> <div class="oc-table card overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="text-left text-xs uppercase tracking-wide" style="color: var(--text-secondary); border-bottom: 1px solid var(--border);"> <tr class="text-left text-xs uppercase tracking-wide" style="color: var(--text-secondary); border-bottom: 1px solid var(--border);">
+1 -1
View File
@@ -155,7 +155,7 @@ func AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, tota
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"oc-table card\"><table class=\"w-full text-sm\"><thead><tr class=\"text-left text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary); border-bottom: 1px solid var(--border);\"><th class=\"px-4 py-3\">Feed</th><th class=\"px-4 py-3\">Library</th><th class=\"px-4 py-3\">Subscribers</th><th class=\"px-4 py-3 text-right\">Downloaded</th><th class=\"px-4 py-3 text-right\">Disk</th><th class=\"px-4 py-3\">Last fetch</th><th class=\"px-4 py-3 text-right\">Actions</th></tr></thead> <tbody>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"oc-table card overflow-x-auto\"><table class=\"w-full text-sm\"><thead><tr class=\"text-left text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary); border-bottom: 1px solid var(--border);\"><th class=\"px-4 py-3\">Feed</th><th class=\"px-4 py-3\">Library</th><th class=\"px-4 py-3\">Subscribers</th><th class=\"px-4 py-3 text-right\">Downloaded</th><th class=\"px-4 py-3 text-right\">Disk</th><th class=\"px-4 py-3\">Last fetch</th><th class=\"px-4 py-3 text-right\">Actions</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -514,7 +514,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
</div> </div>
<!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --> <!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ -->
<div class="w-full max-w-xl flex items-center justify-center gap-2"> <div class="w-full max-w-xl flex flex-wrap items-center justify-center gap-2">
<div class="relative" @click.outside="skipOpen = false"> <div class="relative" @click.outside="skipOpen = false">
<button <button
type="button" type="button"
+1 -1
View File
@@ -615,7 +615,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button><div x-show=\"skipOpen\" x-cloak class=\"absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7.5rem]\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\"><template x-for=\"secs in skipChoices\" :key=\"'skip' + secs\"><button type=\"button\" @click=\"pickSkip(secs)\" class=\"w-full text-left px-3 py-1.5 rounded-md text-sm\" :class=\"isSkipActive(secs) ? 'font-semibold' : ''\" :style=\"isSkipActive(secs) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'\" x-text=\"secs + ' seconds'\"></button></template></div></div><div class=\"relative\" @click.outside=\"speedOpen = false\"><button type=\"button\" @click=\"speedOpen = !speedOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Playback speed, currently ' + speed + ' times'\" style=\"color: var(--text-secondary);\"><span>Speed</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"speed + '×'\">1×</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button><div x-show=\"speedOpen\" x-cloak class=\"absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\"><template x-for=\"rate in speeds\" :key=\"'speed' + rate\"><button type=\"button\" @click=\"pickSpeed(rate)\" class=\"w-full text-left px-3 py-1.5 rounded-md text-sm\" :class=\"isSpeedActive(rate) ? 'font-semibold' : ''\" :style=\"isSpeedActive(rate) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'\" x-text=\"rate + '×' + (isDefaultSpeed(rate) ? ' · default' : '')\"></button></template><!-- §8: pin the current rate as the user default —\n\t\t\t\t\t\t books without their own speed pick it up --><button type=\"button\" x-show=\"!isDefaultSpeed(speed)\" @click=\"pickDefaultSpeed(speed)\" class=\"w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t\" style=\"border-color: var(--border); color: var(--text-secondary);\" x-text=\"'Make ' + speed + '× my default'\"></button></div></div><button type=\"button\" @click=\"toggleBookmarkCapture()\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :class=\"bookmarkCaptureOpen ? 'opacity-100' : 'opacity-70'\" aria-label=\"Add bookmark at current position\" style=\"color: var(--text-secondary);\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path> <path d=\"M12 7v6M9 10h6\"></path></svg> <span>Bookmark</span></button><div class=\"relative\" @click.outside=\"sleepOpen = false\"><button type=\"button\" @click=\"sleepOpen = !sleepOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Sleep timer, currently ' + sleepLabelStr\" style=\"color: var(--text-secondary);\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\"></path></svg> <span class=\"font-semibold tabular-nums\" style=\"color: var(--text-primary);\" x-text=\"sleepLabelStr === 'Off' ? 'Sleep' : sleepLabelStr\">Sleep</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button><div xLine truncated templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex flex-wrap items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button><div x-show=\"skipOpen\" x-cloak class=\"absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7.5rem]\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\"><template x-for=\"secs in skipChoices\" :key=\"'skip' + secs\"><button type=\"button\" @click=\"pickSkip(secs)\" class=\"w-full text-left px-3 py-1.5 rounded-md text-sm\" :class=\"isSkipActive(secs) ? 'font-semibold' : ''\" :style=\"isSkipActive(secs) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'\" x-text=\"secs + ' seconds'\"></button></template></div></div><div class=\"relative\" @click.outside=\"speedOpen = false\"><button type=\"button\" @click=\"speedOpen = !speedOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Playback speed, currently ' + speed + ' times'\" style=\"color: var(--text-secondary);\"><span>Speed</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"speed + '×'\">1×</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button><div x-show=\"speedOpen\" x-cloak class=\"absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\"><template x-for=\"rate in speeds\" :key=\"'speed' + rate\"><button type=\"button\" @click=\"pickSpeed(rate)\" class=\"w-full text-left px-3 py-1.5 rounded-md text-sm\" :class=\"isSpeedActive(rate) ? 'font-semibold' : ''\" :style=\"isSpeedActive(rate) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'\" x-text=\"rate + '×' + (isDefaultSpeed(rate) ? ' · default' : '')\"></button></template><!-- §8: pin the current rate as the user default —\n\t\t\t\t\t\t books without their own speed pick it up --><button type=\"button\" x-show=\"!isDefaultSpeed(speed)\" @click=\"pickDefaultSpeed(speed)\" class=\"w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t\" style=\"border-color: var(--border); color: var(--text-secondary);\" x-text=\"'Make ' + speed + '× my default'\"></button></div></div><button type=\"button\" @click=\"toggleBookmarkCapture()\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :class=\"bookmarkCaptureOpen ? 'opacity-100' : 'opacity-70'\" aria-label=\"Add bookmark at current position\" style=\"color: var(--text-secondary);\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path> <path d=\"M12 7v6M9 10h6\"></path></svg> <span>Bookmark</span></button><div class=\"relative\" @click.outside=\"sleepOpen = false\"><button type=\"button\" @click=\"sleepOpen = !sleepOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Sleep timer, currently ' + sleepLabelStr\" style=\"color: var(--text-secondary);\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\"></path></svg> <span class=\"font-semibold tabular-nums\" style=\"color: var(--text-primary);\" x-text=\"sleepLabelStr === 'Off' ? 'Sleep' : sleepLabelStr\">Sleep</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></butLine truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+2 -2
View File
@@ -659,8 +659,8 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</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 flex-wrap items-center justify-between gap-x-6 gap-y-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);">
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<button <button
@click="rescanBook()" @click="rescanBook()"
:disabled="rescanning" :disabled="rescanning"
+1 -1
View File
@@ -1477,7 +1477,7 @@ func MetadataEditorModal(book handlers.MediaDetail) 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, 156, "\" readonly class=\"input opacity-60 font-mono text-xs\"></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 gap-2\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\" readonly class=\"input opacity-60 font-mono text-xs\"></div></div></div></div></div><div class=\"flex flex-wrap items-center justify-between gap-x-6 gap-y-3 p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><div class=\"flex flex-wrap items-center gap-2\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -284,7 +284,7 @@ templ BookShelf(
<div id="filter-save-error" style="display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;"></div> <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"/> <input type="hidden" name="resource_type" value="media-items"/>
</div> </div>
<div class="flex justify-end gap-2"> <div class="flex flex-wrap justify-end gap-2">
<button type="button" @click="showSaveModal = false" class="btn btn-secondary">Cancel</button> <button type="button" @click="showSaveModal = false" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-secondary">Save Filter</button> <button type="submit" class="btn btn-secondary">Save Filter</button>
<!-- Item 3.5: the bridge — the current tree becomes a live <!-- Item 3.5: the bridge — the current tree becomes a live
+1 -1
View File
@@ -562,7 +562,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, 57, "</button></div><form id=\"save-filter-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\" id=\"save-filter-name\" 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-secondary\">Save Filter</button><!-- Item 3.5: the bridge — the current tree becomes a live\n\t\t\t\t\t\t\t smart collection (the qf engine drives it everywhere:\n\t\t\t\t\t\t\t shelf, collections, play chains). --><button type=\"button\" @click=\"saveFilterAsCollection()\" class=\"btn btn-primary\">Save as Collection</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, 57, "</button></div><form id=\"save-filter-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\" id=\"save-filter-name\" 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 flex-wrap justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-secondary\">Save Filter</button><!-- Item 3.5: the bridge — the current tree becomes a live\n\t\t\t\t\t\t\t smart collection (the qf engine drives it everywhere:\n\t\t\t\t\t\t\t shelf, collections, play chains). --><button type=\"button\" @click=\"saveFilterAsCollection()\" class=\"btn btn-primary\">Save as Collection</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
} }
+5 -5
View File
@@ -1,9 +1,9 @@
package templates package templates
templ CollectionModal(collection CollectionData) { templ CollectionModal(collection CollectionData) {
<div x-data="collections" class="fixed inset-0 z-[65] flex items-center justify-center p-4" style="background-color: var(--surface-overlay);"> <div x-data="collections" class="fixed inset-0 z-[65] flex items-stretch justify-end sm:items-center sm:justify-center sm:p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);"> <div class="app-modal-panel card p-5 sm:p-6 sm:max-w-md">
<div class="flex justify-between items-center mb-6"> <div class="sticky top-0 z-10 flex justify-between items-center -mx-5 px-5 pb-3 mb-5 border-b sm:-mx-6 sm:px-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);"> <span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("folder", "h-5 w-5") @Icon("folder", "h-5 w-5")
@@ -85,7 +85,7 @@ templ CollectionModal(collection CollectionData) {
</label> </label>
</div> </div>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="sticky bottom-0 z-10 flex justify-end space-x-3 -mx-5 px-5 py-3 border-t sm:-mx-6 sm:px-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<button type="button" @click="closeCollectionModal($el)" class="btn btn-secondary">Cancel</button> <button type="button" @click="closeCollectionModal($el)" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Update Collection</button> <button type="submit" class="btn btn-primary">Update Collection</button>
</div> </div>
@@ -185,7 +185,7 @@ templ CollectionModal(collection CollectionData) {
</div> </div>
<input type="hidden" name="color" id="collection-color" value="blue"/> <input type="hidden" name="color" id="collection-color" value="blue"/>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="sticky bottom-0 z-10 flex justify-end space-x-3 -mx-5 px-5 py-3 border-t sm:-mx-6 sm:px-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<button type="button" @click="closeCollectionModal($el)" class="btn btn-secondary">Cancel</button> <button type="button" @click="closeCollectionModal($el)" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Create Collection</button> <button type="submit" class="btn btn-primary">Create Collection</button>
</div> </div>
+3 -3
View File
@@ -29,7 +29,7 @@ func CollectionModal(collection CollectionData) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-[65] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-[65] flex items-stretch justify-end sm:items-center sm:justify-center sm:p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"app-modal-panel card p-5 sm:p-6 sm:max-w-md\"><div class=\"sticky top-0 z-10 flex justify-between items-center -mx-5 px-5 pb-3 mb-5 border-b sm:-mx-6 sm:px-6\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -163,7 +163,7 @@ func CollectionModal(collection CollectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " class=\"h-4 w-4 accent-current\"> <span>Show Up Next strip</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal($el)\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " class=\"h-4 w-4 accent-current\"> <span>Show Up Next strip</span></label></div></div><div class=\"sticky bottom-0 z-10 flex justify-end space-x-3 -mx-5 px-5 py-3 border-t sm:-mx-6 sm:px-6\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><button type=\"button\" @click=\"closeCollectionModal($el)\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -181,7 +181,7 @@ func CollectionModal(collection CollectionData) 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, 19, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal($el)\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"sticky bottom-0 z-10 flex justify-end space-x-3 -mx-5 px-5 py-3 border-t sm:-mx-6 sm:px-6\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><button type=\"button\" @click=\"closeCollectionModal($el)\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -34,7 +34,7 @@ templ CollectionRules(user User, collection CollectionData, fieldsJSON string, t
<span class="grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl" style={ "background-color: var(--accent-muted); color: " + collection.Color }>{ collection.Icon }</span> <span class="grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl" style={ "background-color: var(--accent-muted); color: " + collection.Color }>{ collection.Icon }</span>
<div> <div>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1> <h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
<p class="flex items-center gap-2" style="color: var(--text-secondary)"> <p class="flex flex-wrap items-center gap-2" style="color: var(--text-secondary)">
<span class="badge text-xs" style="background-color: var(--accent-muted); color: var(--accent);">⚡ Smart</span> <span class="badge text-xs" style="background-color: var(--accent-muted); color: var(--accent);">⚡ Smart</span>
if scope == "episodes" { if scope == "episodes" {
Episodes scope — membership recomputes live from the tree below. Episodes scope — membership recomputes live from the tree below.
+1 -1
View File
@@ -152,7 +152,7 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
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, "</h1><p class=\"flex items-center gap-2\" style=\"color: var(--text-secondary)\"><span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</h1><p class=\"flex flex-wrap items-center gap-2\" style=\"color: var(--text-secondary)\"><span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+3 -3
View File
@@ -60,15 +60,15 @@ templ ConflictsListContent(user User, conflicts []handlers.ConflictDetailRespons
templ ConflictStatsBar(total int, unresolved int) { templ ConflictStatsBar(total int, unresolved int) {
<div class="stat-card text-center py-3"> <div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div> <div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div> <div class="text-xs uppercase tracking-normal" style="color: var(--text-secondary);">Total</div>
</div> </div>
<div class="stat-card text-center py-3"> <div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div> <div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div> <div class="text-xs uppercase tracking-normal" style="color: var(--text-secondary);">Unresolved</div>
</div> </div>
<div class="stat-card text-center py-3"> <div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div> <div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div> <div class="text-xs uppercase tracking-normal" style="color: var(--text-secondary);">Resolved</div>
</div> </div>
} }
+3 -3
View File
@@ -219,7 +219,7 @@ func ConflictStatsBar(total int, unresolved int) 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, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Total</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-warning);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div class=\"text-xs uppercase tracking-normal\" style=\"color: var(--text-secondary);\">Total</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -232,7 +232,7 @@ func ConflictStatsBar(total int, unresolved int) 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, 18, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Unresolved</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-success);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div><div class=\"text-xs uppercase tracking-normal\" style=\"color: var(--text-secondary);\">Unresolved</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-success);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -245,7 +245,7 @@ func ConflictStatsBar(total int, unresolved int) 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, 19, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Resolved</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"text-xs uppercase tracking-normal\" style=\"color: var(--text-secondary);\">Resolved</div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -136,7 +136,7 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
</div> </div>
</div> </div>
<div <div
id="search-results" id="cs-search-results"
class="hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2" class="hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2"
style="background-color: var(--bg-primary);" style="background-color: var(--bg-primary);"
></div> ></div>
+1 -1
View File
@@ -143,7 +143,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
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, "Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-xl border-2 border-dashed\" style=\"border-color: var(--border-strong); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Search</button></div></div><div id=\"cs-search-results\" class=\"hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-xl border-2 border-dashed\" style=\"border-color: var(--border-strong); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+12 -12
View File
@@ -70,7 +70,7 @@ templ CollectionCarousel(section handlers.SectionData) {
if section.ViewAllURL != "" { if section.ViewAllURL != "" {
<a <a
href={ section.ViewAllURL } href={ section.ViewAllURL }
class="inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors" class="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-sm font-medium hover:underline transition-colors"
style="color: var(--accent);" style="color: var(--accent);"
> >
View All View All
@@ -220,11 +220,11 @@ templ BookCard(item handlers.BookInfo) {
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) { templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
<div <div
id="dashboard-settings-modal" id="dashboard-settings-modal"
class="hidden fixed inset-0 z-[70] flex items-center justify-center p-4" class="hidden fixed inset-0 z-[70] flex items-stretch justify-end sm:items-center sm:justify-center sm:p-4"
style="background-color: var(--surface-overlay);" style="background-color: var(--surface-overlay);"
> >
<div class="card p-6 w-full max-w-2xl shadow-2xl" style="box-shadow: var(--shadow-pop);"> <div class="app-modal-panel card p-5 sm:p-6 sm:max-w-2xl">
<div class="flex justify-between items-center mb-6"> <div class="sticky top-0 z-10 flex justify-between items-start mb-6 pb-2" style="background-color: var(--bg-secondary);">
<div> <div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
<p class="text-sm mt-1" style="color: var(--text-secondary)">Drag to reorder, toggle to show or hide.</p> <p class="text-sm mt-1" style="color: var(--text-secondary)">Drag to reorder, toggle to show or hide.</p>
@@ -237,22 +237,22 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
<div id="collection-list" class="space-y-2 mb-6"> <div id="collection-list" class="space-y-2 mb-6">
for _, section := range sections { for _, section := range sections {
<div <div
class="collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card" class="collection-item flex flex-wrap items-center justify-between gap-x-3 gap-y-2 p-3 rounded-xl cursor-move select-none card"
data-collection-id={ section.ID } data-collection-id={ section.ID }
data-is-system={ fmt.Sprintf("%v", section.IsSystem) } data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
draggable="true" draggable="true"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 min-w-0">
@Icon("grip", "h-5 w-5 shrink-0") @Icon("grip", "h-5 w-5 shrink-0")
<span class="text-xl">{ section.Icon }</span> <span class="text-xl shrink-0">{ section.Icon }</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 min-w-0">
<span class="font-medium" style="color: var(--text-primary)">{ section.Title }</span> <span class="font-medium truncate" style="color: var(--text-primary)">{ section.Title }</span>
if section.IsSystem { if section.IsSystem {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">System</span> <span class="badge shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">System</span>
} }
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 shrink-0 ml-auto">
if section.IsSystem { if section.IsSystem {
<button <button
data-action="restore-system-collection" data-action="restore-system-collection"
@@ -296,7 +296,7 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
target="items-count-display" target="items-count-display"
/> />
</div> </div>
<div class="flex justify-end gap-3"> <div class="sticky bottom-0 z-10 flex justify-end gap-3 -mx-5 px-5 py-3 border-t sm:-mx-6 sm:px-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<button data-action="close-dashboard-settings" class="btn btn-secondary">Cancel</button> <button data-action="close-dashboard-settings" class="btn btn-secondary">Cancel</button>
<button data-action="save-dashboard-settings" class="btn btn-primary">Save Changes</button> <button data-action="save-dashboard-settings" class="btn btn-primary">Save Changes</button>
</div> </div>
+11 -11
View File
@@ -256,7 +256,7 @@ func CollectionCarousel(section handlers.SectionData) 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, 21, "\" class=\"inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors\" style=\"color: var(--accent);\">View All") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-sm font-medium hover:underline transition-colors\" style=\"color: var(--accent);\">View All")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -747,7 +747,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
templ_7745c5c3_Var34 = templ.NopComponent templ_7745c5c3_Var34 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl shadow-2xl\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Drag to reorder, toggle to show or hide.</p><p class=\"text-xs mt-1 font-medium\" id=\"dashboard-scope-label\" style=\"color: var(--accent)\"></p></div><button data-action=\"close-dashboard-settings\" class=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-[70] flex items-stretch justify-end sm:items-center sm:justify-center sm:p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"app-modal-panel card p-5 sm:p-6 sm:max-w-2xl\"><div class=\"sticky top-0 z-10 flex justify-between items-start mb-6 pb-2\" style=\"background-color: var(--bg-secondary);\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Drag to reorder, toggle to show or hide.</p><p class=\"text-xs mt-1 font-medium\" id=\"dashboard-scope-label\" style=\"color: var(--accent)\"></p></div><button data-action=\"close-dashboard-settings\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -760,7 +760,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, section := range sections { for _, section := range sections {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<div class=\"collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card\" data-collection-id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<div class=\"collection-item flex flex-wrap items-center justify-between gap-x-3 gap-y-2 p-3 rounded-xl cursor-move select-none card\" data-collection-id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -786,7 +786,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
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, 75, "\" draggable=\"true\"><div class=\"flex items-center gap-3\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" draggable=\"true\"><div class=\"flex items-center gap-3 min-w-0\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -794,27 +794,27 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
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, 76, "<span class=\"text-xl\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"text-xl shrink-0\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var37 string var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon) templ_7745c5c3_Var37, 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: `dashboard.templ`, Line: 247, Col: 43} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 247, Col: 52}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
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, 77, "</span><div class=\"flex items-center gap-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span><div class=\"flex items-center gap-2 min-w-0\"><span class=\"font-medium truncate\" 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_Var38 string var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) templ_7745c5c3_Var38, 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: `dashboard.templ`, Line: 249, Col: 84} return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 249, Col: 93}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -825,12 +825,12 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if section.IsSystem { if section.IsSystem {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">System</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"badge shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">System</span>")
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, 80, "</div></div><div class=\"flex items-center gap-3\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</div></div><div class=\"flex items-center gap-3 shrink-0 ml-auto\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -907,7 +907,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
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, 89, "\" class=\"w-full h-2 rounded-lg appearance-none cursor-pointer\" style=\"background-color: var(--border-strong);\" data-input-action=\"update-items-count\" target=\"items-count-display\"></div><div class=\"flex justify-end gap-3\"><button data-action=\"close-dashboard-settings\" class=\"btn btn-secondary\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"btn btn-primary\">Save Changes</button></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" class=\"w-full h-2 rounded-lg appearance-none cursor-pointer\" style=\"background-color: var(--border-strong);\" data-input-action=\"update-items-count\" target=\"items-count-display\"></div><div class=\"sticky bottom-0 z-10 flex justify-end gap-3 -mx-5 px-5 py-3 border-t sm:-mx-6 sm:px-6\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><button data-action=\"close-dashboard-settings\" class=\"btn btn-secondary\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"btn btn-primary\">Save Changes</button></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+10 -4
View File
@@ -120,8 +120,11 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
<span>Refresh</span> <span>Refresh</span>
</button> </button>
<!-- Order override (D5): per-user; refreshes the episode list --> <!-- Order override (D5): per-user; refreshes the episode list.
<label class="text-sm ml-2" for="podcast-order-select" style="color: var(--text-secondary)">Order</label> Label+select travel as a pair so flex-wrap can never
separate them on narrow screens. -->
<div class="flex items-center gap-1.5">
<label class="text-sm" for="podcast-order-select" style="color: var(--text-secondary)">Order</label>
<select <select
id="podcast-order-select" id="podcast-order-select"
name="order" name="order"
@@ -135,6 +138,7 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
<option value="oldest" selected?={ data.Order == "oldest" }>Oldest first (pub date)</option> <option value="oldest" selected?={ data.Order == "oldest" }>Oldest first (pub date)</option>
<option value="newest" selected?={ data.Order == "newest" }>Newest first (pub date)</option> <option value="newest" selected?={ data.Order == "newest" }>Newest first (pub date)</option>
</select> </select>
</div>
<!-- Serial suggestion (D5, §0 item 3 rename): one-click, <!-- Serial suggestion (D5, §0 item 3 rename): one-click,
never silent. "reverse" sorts by FEED POSITION DESC — never silent. "reverse" sorts by FEED POSITION DESC —
@@ -152,8 +156,9 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
</button> </button>
} }
<!-- Auto-download policy (D6) --> <!-- Auto-download policy (D6) — label+controls as one pair -->
<label class="text-sm ml-2" for="podcast-autodl-select" style="color: var(--text-secondary)">Auto-download</label> <div class="flex items-center gap-1.5">
<label class="text-sm" for="podcast-autodl-select" style="color: var(--text-secondary)">Auto-download</label>
@PodcastAutoDownloadSelect(data.Feed) @PodcastAutoDownloadSelect(data.Feed)
if data.Feed.AutoDownload == "newest" { if data.Feed.AutoDownload == "newest" {
<input <input
@@ -167,6 +172,7 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
aria-label="Newest N episodes" aria-label="Newest N episodes"
/> />
} }
</div>
<!-- Unsubscribe (issues #4/#7): the sole subscriber who is <!-- Unsubscribe (issues #4/#7): the sole subscriber who is
an admin gets the three-choice modal (unsubscribe / an admin gets the three-choice modal (unsubscribe /
+42 -42
View File
@@ -359,14 +359,14 @@ func PodcastDetailPage(user User, data PodcastDetailData) 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, 32, "<span>Refresh</span></button><!-- Order override (D5): per-user; refreshes the episode list --><label class=\"text-sm ml-2\" for=\"podcast-order-select\" style=\"color: var(--text-secondary)\">Order</label> <select id=\"podcast-order-select\" name=\"order\" class=\"input w-auto py-1.5 cursor-pointer\" hx-put=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span>Refresh</span></button><!-- Order override (D5): per-user; refreshes the episode list.\n\t\t\t\t\t Label+select travel as a pair so flex-wrap can never\n\t\t\t\t\t separate them on narrow screens. --><div class=\"flex items-center gap-1.5\"><label class=\"text-sm\" for=\"podcast-order-select\" style=\"color: var(--text-secondary)\">Order</label> <select id=\"podcast-order-select\" name=\"order\" class=\"input w-auto py-1.5 cursor-pointer\" hx-put=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/order") templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/order")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 129, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 132, Col: 64}
} }
_, 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 {
@@ -412,7 +412,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Newest first (pub date)</option></select><!-- Serial suggestion (D5, §0 item 3 rename): one-click,\n\t\t\t\t\t never silent. \"reverse\" sorts by FEED POSITION DESC —\n\t\t\t\t\t no pub dates involved, so messy dates can't break it. -->") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Newest first (pub date)</option></select></div><!-- Serial suggestion (D5, §0 item 3 rename): one-click,\n\t\t\t\t\t never silent. \"reverse\" sorts by FEED POSITION DESC —\n\t\t\t\t\t no pub dates involved, so messy dates can't break it. -->")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -424,7 +424,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/order") templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/order")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 145, Col: 64} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 149, Col: 64}
} }
_, 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 {
@@ -435,7 +435,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<!-- Auto-download policy (D6) --><label class=\"text-sm ml-2\" for=\"podcast-autodl-select\" style=\"color: var(--text-secondary)\">Auto-download</label>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<!-- Auto-download policy (D6) — label+controls as one pair --><div class=\"flex items-center gap-1.5\"><label class=\"text-sm\" for=\"podcast-autodl-select\" style=\"color: var(--text-secondary)\">Auto-download</label>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -451,7 +451,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(podcastInt(data.Feed.AutoDownloadCount)) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(podcastInt(data.Feed.AutoDownloadCount))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 165, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 170, 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 {
@@ -462,7 +462,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<!-- Unsubscribe (issues #4/#7): the sole subscriber who is\n\t\t\t\t\t an admin gets the three-choice modal (unsubscribe /\n\t\t\t\t\t remove feed / cancel); everyone else gets a plain\n\t\t\t\t\t confirm — file retention is admin knowledge only. -->") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div><!-- Unsubscribe (issues #4/#7): the sole subscriber who is\n\t\t\t\t\t an admin gets the three-choice modal (unsubscribe /\n\t\t\t\t\t remove feed / cancel); everyone else gets a plain\n\t\t\t\t\t confirm — file retention is admin knowledge only. -->")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -479,7 +479,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/subscription") templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/subscription")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 182, Col: 74} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 188, Col: 74}
} }
_, 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 {
@@ -497,7 +497,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/subscription") templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/subscription")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 191, Col: 74} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 197, Col: 74}
} }
_, 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 {
@@ -516,7 +516,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 203, Col: 56} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 209, Col: 56}
} }
_, 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 {
@@ -555,7 +555,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(data.GapCount) + " episode(s) not downloaded in the middle of this feed") templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(data.GapCount) + " episode(s) not downloaded in the middle of this feed")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 218, Col: 160} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 224, Col: 160}
} }
_, 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 {
@@ -578,7 +578,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/episodes/delete-files") templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/episodes/delete-files")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 258, Col: 82} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 264, Col: 82}
} }
_, 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 {
@@ -651,7 +651,7 @@ func PodcastEpisodeList(data PodcastDetailData) templ.Component {
var templ_7745c5c3_Var27 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("/podcasts/" + data.Feed.ID + "/episodes") templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("/podcasts/" + data.Feed.ID + "/episodes")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 294, Col: 52} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 300, Col: 52}
} }
_, 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 {
@@ -721,7 +721,7 @@ func PodcastEpisodeListPolling(data PodcastDetailData) templ.Component {
var templ_7745c5c3_Var29 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue("/podcasts/" + data.Feed.ID + "/episodes") templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue("/podcasts/" + data.Feed.ID + "/episodes")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 319, Col: 52} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 325, Col: 52}
} }
_, 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 {
@@ -773,7 +773,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var31 string var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolAttr(ep.Downloaded)) templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolAttr(ep.Downloaded))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 336, Col: 43} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 342, Col: 43}
} }
_, 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 {
@@ -786,7 +786,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var32 string var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(podcastPct(ep.ListenPct)) templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(podcastPct(ep.ListenPct))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 337, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 343, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -799,7 +799,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var33 string var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(ep.Title + " " + ep.Description)) templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(ep.Title + " " + ep.Description))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 338, Col: 69} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 344, Col: 69}
} }
_, 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 {
@@ -818,7 +818,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var34 string var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(ep.ID) templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(ep.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 351, Col: 18} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 357, Col: 18}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -831,7 +831,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var35 string var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("Select " + ep.Title + " for file deletion") templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("Select " + ep.Title + " for file deletion")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 353, Col: 61} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 359, Col: 61}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -855,7 +855,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var36 string var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(ep.Position)) templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(ep.Position))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 361, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 367, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -868,7 +868,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var37 templ.SafeURL var templ_7745c5c3_Var37 templ.SafeURL
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(ep.PlayURL) templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(ep.PlayURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 365, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 371, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -881,7 +881,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var38 string var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Title) templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 365, Col: 129} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 371, Col: 129}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -899,7 +899,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var39 string var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 367, Col: 165} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 373, Col: 165}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -917,7 +917,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var40 string var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 369, Col: 134} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 375, Col: 134}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -935,7 +935,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var41 string var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 371, Col: 133} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 377, Col: 133}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -958,7 +958,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var42 string var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(ep.DownloadError) templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(ep.DownloadError)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 375, Col: 97} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 381, Col: 97}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -982,7 +982,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var43 string var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(progressWidth(ep.ProgressPct) + " background-color: var(--accent);") templ_7745c5c3_Var43, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(progressWidth(ep.ProgressPct) + " background-color: var(--accent);")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 381, Col: 130} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 387, Col: 130}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -995,7 +995,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var44 string var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal)) templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 384, Col: 93} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 390, Col: 93}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1013,7 +1013,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var45 string var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived)) templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 390, Col: 122} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 396, Col: 122}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1036,7 +1036,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var46 string var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Date) templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Date)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 395, Col: 13} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 401, Col: 13}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1050,7 +1050,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var47 string var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Duration) templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Duration)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 397, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 403, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1065,7 +1065,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var48 string var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Size) templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Size)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 400, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 406, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1084,7 +1084,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var49 templ.SafeURL var templ_7745c5c3_Var49 templ.SafeURL
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(ep.PlayURL) templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(ep.PlayURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 405, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 411, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1145,7 +1145,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var52 string var templ_7745c5c3_Var52 string
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + ep.FeedID + "/episodes/" + ep.ID + "/download") templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + ep.FeedID + "/episodes/" + ep.ID + "/download")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 417, Col: 85} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 423, Col: 85}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1158,7 +1158,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var53 string var templ_7745c5c3_Var53 string
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(downloadButtonTitle(ep.DownloadState)) templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(downloadButtonTitle(ep.DownloadState))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 419, Col: 49} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 425, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1179,7 +1179,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
var templ_7745c5c3_Var54 string var templ_7745c5c3_Var54 string
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(downloadButtonLabel(ep.DownloadState)) templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(downloadButtonLabel(ep.DownloadState))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 422, Col: 49} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 428, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1258,7 +1258,7 @@ func PodcastAutoDownloadSelect(feed PodcastFeedCard) templ.Component {
var templ_7745c5c3_Var56 string var templ_7745c5c3_Var56 string
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + feed.ID + "/subscription") templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + feed.ID + "/subscription")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 466, Col: 61} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 472, Col: 61}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1334,7 +1334,7 @@ func PodcastOrderSelectOOB(feedID string, order string) templ.Component {
var templ_7745c5c3_Var58 string var templ_7745c5c3_Var58 string
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + feedID + "/order") templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + feedID + "/order")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 486, Col: 53} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 492, Col: 53}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1418,7 +1418,7 @@ func PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) templ.Component {
var templ_7745c5c3_Var60 string var templ_7745c5c3_Var60 string
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + feed.ID + "/subscription") templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + feed.ID + "/subscription")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 505, Col: 61} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 511, Col: 61}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1494,7 +1494,7 @@ func PodcastUnsubscribeModal(data PodcastDetailData) templ.Component {
var templ_7745c5c3_Var62 string var templ_7745c5c3_Var62 string
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.DisplayTitle()) templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.DisplayTitle())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 530, Col: 112} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 536, Col: 112}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1507,7 +1507,7 @@ func PodcastUnsubscribeModal(data PodcastDetailData) templ.Component {
var templ_7745c5c3_Var63 string var templ_7745c5c3_Var63 string
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/subscription") templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/subscription")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 536, Col: 72} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 542, Col: 72}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -1520,7 +1520,7 @@ func PodcastUnsubscribeModal(data PodcastDetailData) templ.Component {
var templ_7745c5c3_Var64 string var templ_7745c5c3_Var64 string
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID) templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 545, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 551, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+2 -2
View File
@@ -104,9 +104,9 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
</div> </div>
} }
if item.EpubCFI != "" { if item.EpubCFI != "" {
<div class="mt-2 flex items-center gap-2 text-xs" style="color: var(--text-secondary);"> <div class="mt-2 flex items-center gap-2 text-xs min-w-0" style="color: var(--text-secondary);">
@Icon("bookmark", "h-3.5 w-3.5") @Icon("bookmark", "h-3.5 w-3.5")
<span>CFI: { item.EpubCFI }</span> <span class="min-w-0 break-all">CFI: { item.EpubCFI }</span>
</div> </div>
} }
</div> </div>
+3 -3
View File
@@ -308,7 +308,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
} }
} }
if item.EpubCFI != "" { if item.EpubCFI != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"mt-2 flex items-center gap-2 text-xs\" style=\"color: var(--text-secondary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"mt-2 flex items-center gap-2 text-xs min-w-0\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -316,14 +316,14 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
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, "<span>CFI: ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"min-w-0 break-all\">CFI: ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI) templ_7745c5c3_Var19, 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: `progress.templ`, Line: 109, Col: 36} return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 109, Col: 62}
} }
_, 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 {
+1 -1
View File
@@ -29,7 +29,7 @@ templ Stats(user User) {
<!-- READING tab — behavior stats from sync pushes (all libraries) --> <!-- READING tab — behavior stats from sync pushes (all libraries) -->
<div id="stats-reading-section"> <div id="stats-reading-section">
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-3"> <div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-3">
<div class="flex gap-2" id="stats-range-selector"> <div class="flex flex-wrap gap-2" id="stats-range-selector">
<button data-range="7" @click="selectRange('7')" class="stats-range-btn btn btn-secondary text-sm">Last 7 days</button> <button data-range="7" @click="selectRange('7')" class="stats-range-btn btn btn-secondary text-sm">Last 7 days</button>
<button data-range="30" @click="selectRange('30')" class="stats-range-btn btn btn-secondary text-sm">30 days</button> <button data-range="30" @click="selectRange('30')" class="stats-range-btn btn btn-secondary text-sm">30 days</button>
<button data-range="90" @click="selectRange('90')" class="stats-range-btn btn btn-secondary text-sm">90 days</button> <button data-range="90" @click="selectRange('90')" class="stats-range-btn btn btn-secondary text-sm">90 days</button>
+1 -1
View File
@@ -80,7 +80,7 @@ func Stats(user User) 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, 6, ") · totals derive from your devices' sync pushes, not a stopwatch.</p><div class=\"flex gap-2 mt-4\" id=\"stats-tab-row\"><button data-tab=\"reading\" @click=\"selectTab('reading')\" class=\"stats-tab-btn btn btn-secondary text-sm\">Reading</button> <button data-tab=\"library\" @click=\"selectTab('library')\" class=\"stats-tab-btn btn btn-secondary text-sm\">Library</button></div></div><!-- READING tab — behavior stats from sync pushes (all libraries) --><div id=\"stats-reading-section\"><div class=\"card p-4 mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex gap-2\" id=\"stats-range-selector\"><button data-range=\"7\" @click=\"selectRange('7')\" class=\"stats-range-btn btn btn-secondary text-sm\">Last 7 days</button> <button data-range=\"30\" @click=\"selectRange('30')\" class=\"stats-range-btn btn btn-secondary text-sm\">30 days</button> <button data-range=\"90\" @click=\"selectRange('90')\" class=\"stats-range-btn btn btn-secondary text-sm\">90 days</button> <button data-range=\"year\" @click=\"selectRange('year')\" class=\"stats-range-btn btn btn-secondary text-sm\">This year</button></div><p class=\"text-xs\" style=\"color: var(--text-secondary)\" id=\"stats-window-label\"></p></div><div class=\"grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Reading Time</h3><p id=\"stats-total-minutes\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\" id=\"stats-pages-card-label\">Pages</h3><p id=\"stats-total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Streaks</h3><p id=\"stats-streaks\" class=\"text-lg font-bold leading-tight mt-1\" style=\"color: var(--text-primary)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Books Finished</h3><p id=\"stats-books-finished\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Most Active</h3><p id=\"stats-most-active\" class=\"text-sm font-bold leading-snug mt-2\" style=\"color: var(--text-primary)\">–</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Daily Reading Minutes</h3><div class=\"h-64\"><canvas id=\"stats-daily-minutes-chart\"></canvas></div></div><div class=\"card p-6\" id=\"stats-pages-chart-panel\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\" id=\"stats-pages-chart-label\">Pages per Day</h3><div class=\"h-64\"><canvas id=\"stats-daily-pages-chart\"></canvas></div></div><div class=\"card p-6 hidden\" id=\"stats-listening-chart-panel\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Listening per Day (hrs)</h3><div class=\"h-64\"><canvas id=\"stats-daily-listening-chart\"></canvas></div></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Library</h3><div id=\"stats-library-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Device</h3><p class=\"text-xs mb-3\" style=\"color: var(--text-secondary)\">Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).</p><div id=\"stats-device-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Most Read</h3><div id=\"stats-top-books\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div></div><div id=\"stats-empty\" class=\"card p-8 text-center hidden\"><p class=\"text-sm mb-2\" style=\"color: var(--text-primary)\">No reading sessions recorded in this range yet.</p><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Sessions are captured every time a deviLine truncated templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ") · totals derive from your devices' sync pushes, not a stopwatch.</p><div class=\"flex gap-2 mt-4\" id=\"stats-tab-row\"><button data-tab=\"reading\" @click=\"selectTab('reading')\" class=\"stats-tab-btn btn btn-secondary text-sm\">Reading</button> <button data-tab=\"library\" @click=\"selectTab('library')\" class=\"stats-tab-btn btn btn-secondary text-sm\">Library</button></div></div><!-- READING tab — behavior stats from sync pushes (all libraries) --><div id=\"stats-reading-section\"><div class=\"card p-4 mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex flex-wrap gap-2\" id=\"stats-range-selector\"><button data-range=\"7\" @click=\"selectRange('7')\" class=\"stats-range-btn btn btn-secondary text-sm\">Last 7 days</button> <button data-range=\"30\" @click=\"selectRange('30')\" class=\"stats-range-btn btn btn-secondary text-sm\">30 days</button> <button data-range=\"90\" @click=\"selectRange('90')\" class=\"stats-range-btn btn btn-secondary text-sm\">90 days</button> <button data-range=\"year\" @click=\"selectRange('year')\" class=\"stats-range-btn btn btn-secondary text-sm\">This year</button></div><p class=\"text-xs\" style=\"color: var(--text-secondary)\" id=\"stats-window-label\"></p></div><div class=\"grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Reading Time</h3><p id=\"stats-total-minutes\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\" id=\"stats-pages-card-label\">Pages</h3><p id=\"stats-total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Streaks</h3><p id=\"stats-streaks\" class=\"text-lg font-bold leading-tight mt-1\" style=\"color: var(--text-primary)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Books Finished</h3><p id=\"stats-books-finished\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Most Active</h3><p id=\"stats-most-active\" class=\"text-sm font-bold leading-snug mt-2\" style=\"color: var(--text-primary)\">–</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Daily Reading Minutes</h3><div class=\"h-64\"><canvas id=\"stats-daily-minutes-chart\"></canvas></div></div><div class=\"card p-6\" id=\"stats-pages-chart-panel\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\" id=\"stats-pages-chart-label\">Pages per Day</h3><div class=\"h-64\"><canvas id=\"stats-daily-pages-chart\"></canvas></div></div><div class=\"card p-6 hidden\" id=\"stats-listening-chart-panel\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Listening per Day (hrs)</h3><div class=\"h-64\"><canvas id=\"stats-daily-listening-chart\"></canvas></div></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Library</h3><div id=\"stats-library-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Device</h3><p class=\"text-xs mb-3\" style=\"color: var(--text-secondary)\">Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).</p><div id=\"stats-device-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Most Read</h3><div id=\"stats-top-books\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div></div><div id=\"stats-empty\" class=\"card p-8 text-center hidden\"><p class=\"text-sm mb-2\" style=\"color: var(--text-primary)\">No reading sessions recorded in this range yet.</p><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Sessions are captured every tLine truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+9
View File
@@ -40,6 +40,15 @@ Gate: every line below executes for BOTH personas unless marked
- Row 23 chains by page navigation — assert the URL becomes the next - Row 23 chains by page navigation — assert the URL becomes the next
episode's reader and progress carries over. episode's reader and progress carries over.
## 2026-11-02 — promise-API confirm probe
`promise_confirm_probe.spec.ts` (no env gating): the confirmDialog()
promise path (purge archived, remove account, …) must wire the global
modal's buttons even when no hx-confirm element fired first — the
Purge All Archived dialog opened with dead Cancel/Confirm otherwise
(user report; confirm.ts now wires at init + on every confirmDialog).
The probe intercepts the purge POST, so nothing is deleted.
## 2026-10-30 — round-3 regression probes (live-server, env-gated) ## 2026-10-30 — round-3 regression probes (live-server, env-gated)
Two specs added against a RUNNING dev server (skip unless Two specs added against a RUNNING dev server (skip unless
+53
View File
@@ -0,0 +1,53 @@
// Regression probe (2026-11-02): the CONFIRM PROMISE API path must wire
// the global modal's buttons too. purgeArchivedItems() (admin archived
// page) opens confirmDialog() directly — no hx-confirm element fires —
// and both buttons were dead whenever no hx-confirm had been clicked
// first: the modal opened and Cancel/Confirm did nothing (user report).
// The purge request itself is INTERCEPTED here — nothing is deleted.
import { test, expect } from "@playwright/test";
const BASE = process.env.BH_BASE ?? "http://localhost:8765";
test("confirmDialog promise path wires Cancel/Confirm", async ({ page }) => {
await page.addInitScript(() => {
const t = new URLSearchParams(window.location.search).get("token");
if (t) localStorage.setItem("token", t);
});
// testuser login via endpoint, then seed cookie+localStorage like the
// other probes (rls_sweep pattern, kept inline to stay one file).
const res = await page.request.post(`${BASE}/api/auth/login`, {
data: { login: "testuser", password: "Test@Pass123!" },
});
const { access_token: token } = (await res.json()) as { access_token?: string };
expect(token).toBeTruthy();
await page.context().addCookies([{ name: "token", value: token!, url: BASE }]);
await page.addInitScript((t) => localStorage.setItem("token", t), token!);
// The purge POST is intercepted: Cancel/Confirm behavior is asserted
// without ever deleting the dev data.
let purgeRequested = false;
await page.route("**/api/media-items/purge-archived", (route) => {
purgeRequested = true;
route.fulfill({ status: 200, contentType: "application/json", body: '{"purged":0}' });
});
await page.goto(`${BASE}/admin/archived`);
await expect(page.locator("#confirm-modal")).toBeAttached();
// Fresh page: no hx-confirm has fired, so pre-fix the buttons were
// unwired (data-wired absent) and Cancel did nothing.
await page.getByRole("button", { name: "Purge All Archived" }).click();
const modal = page.locator("#confirm-modal");
await expect(modal).toBeVisible();
await expect(page.locator("#confirm-modal-cancel")).toHaveAttribute("data-wired", "1");
await page.locator("#confirm-modal-cancel").click();
await expect(modal).toBeHidden();
// Confirm path: modal closes AND the guarded request issues.
await page.getByRole("button", { name: "Purge All Archived" }).click();
await expect(modal).toBeVisible();
await page.locator("#confirm-modal-accept").click();
await expect(modal).toBeHidden();
expect(purgeRequested).toBe(true);
});
+12 -1
View File
@@ -25,6 +25,7 @@ function hide(ok: boolean): void {
* Safe to call from any module (import this instead of window.confirm). * Safe to call from any module (import this instead of window.confirm).
*/ */
export function confirmDialog(message: string): Promise<boolean> { export function confirmDialog(message: string): Promise<boolean> {
wireButtons();
const { overlay, message: messageEl } = modalElements(); const { overlay, message: messageEl } = modalElements();
if (!overlay || !messageEl) { if (!overlay || !messageEl) {
// Header missing (bare layouts): degrade to the native dialog // Header missing (bare layouts): degrade to the native dialog
@@ -38,7 +39,13 @@ export function confirmDialog(message: string): Promise<boolean> {
}); });
} }
// Wire the static buttons once. // Wire the static buttons once. Called at module init AND from both
// entry paths below (idempotent via data-wired): the htmx:confirm
// interceptor and the confirmDialog promise API. Wiring only on the
// htmx path left the promise path (purge archived, remove account, ...)
// with dead Cancel/Confirm buttons whenever no hx-confirm element had
// been clicked first — the modal opened and nothing could close it
// (user report: Purge Archived, 2026-11-02).
function wireButtons(): void { function wireButtons(): void {
const { overlay } = modalElements(); const { overlay } = modalElements();
const accept = document.getElementById("confirm-modal-accept"); const accept = document.getElementById("confirm-modal-accept");
@@ -60,6 +67,10 @@ function wireButtons(): void {
} }
} }
// The header (and its confirm modal) render before deferred modules run,
// so the elements are present at import time.
wireButtons();
// htmx:confirm interceptor: every element with hx-confirm gets the // htmx:confirm interceptor: every element with hx-confirm gets the
// modal instead of the browser dialog; confirming issues the request. // modal instead of the browser dialog; confirming issues the request.
// (Canonical htmx pattern — see htmx docs, "confirming with a modal".) // (Canonical htmx pattern — see htmx docs, "confirming with a modal".)
+62 -17
View File
@@ -257,6 +257,27 @@ function initCustomSectionBuilder(): void {
if (form) { if (form) {
form.addEventListener("submit", saveCustomSection); form.addEventListener("submit", saveCustomSection);
} }
// Delegated clicks for the generated picker/selection markup: the CSP
// build refuses inline onclick= handlers, so the rows carry data
// attributes and one listener per container survives innerHTML swaps.
const resultsContainer = document.getElementById("cs-search-results");
resultsContainer?.addEventListener("click", (e) => {
const row = (e.target as HTMLElement).closest<HTMLElement>("[data-book-id]");
if (!row) return;
addBookToSelection(
row.dataset.bookId ?? "",
row.dataset.bookTitle ?? "",
row.dataset.bookAuthor ?? "",
);
});
const selectedContainer = document.getElementById("selected-books");
selectedContainer?.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>("[data-remove-book]");
if (!btn) return;
removeBookFromSelection(btn.dataset.removeBook ?? "");
});
} }
function addFilterRule(): void { function addFilterRule(): void {
@@ -363,7 +384,11 @@ function onBookSearchInput(): void {
async function searchBooks(): Promise<void> { async function searchBooks(): Promise<void> {
const searchInput = document.getElementById("book-search") as HTMLInputElement; const searchInput = document.getElementById("book-search") as HTMLInputElement;
const librarySelect = document.getElementById("section-library") as HTMLSelectElement; const librarySelect = document.getElementById("section-library") as HTMLSelectElement;
const resultsContainer = document.getElementById("search-results") as HTMLElement; // NOTE: cs-search-results, NOT search-results — the header search
// dropdown owns the latter on every page, and getElementById returns
// the first match in tree order (the picker's results were landing in
// the topbar dropdown, which stayed invisible).
const resultsContainer = document.getElementById("cs-search-results") as HTMLElement;
const query = searchInput?.value.trim(); const query = searchInput?.value.trim();
const libraryId = librarySelect?.value; const libraryId = librarySelect?.value;
@@ -374,22 +399,48 @@ async function searchBooks(): Promise<void> {
} }
try { try {
// The unified JSON search (same endpoint the bookshelf uses). The
// builder was wired to /api/books/search, a route that has never
// existed — every picker search 404'd. Rows come back with id/title/
// author/cover_image_path; map to the BookInfo shape the picker
// renders (conflict/badge metadata is attached server-side later).
const response = await fetch( const response = await fetch(
`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`, `/api/media-items/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`,
{ {
headers: { headers: {
...authHeaders(), ...authHeaders(),
"Content-Type": "application/json",
}, },
}, },
); );
if (response.status === 404) {
// Unified search signals "no results" as a 404 whose body still
// carries results: [].
displaySearchResults([]);
return;
}
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to search books"); throw new Error("Failed to search books");
} }
const data = await response.json(); const data = await response.json();
displaySearchResults(data.books || []); const rows: Array<{
id?: string;
title?: string;
author?: string | null;
cover_image_path?: string | null;
}> = Array.isArray(data) ? data : data.results || [];
const books: BookInfo[] = rows.map((r) => ({
media_item_id: r.id ?? "",
title: r.title ?? "",
author: r.author ?? "",
cover_image_path: r.cover_image_path ?? "",
has_conflict: false,
format_group: "",
badge: "",
}));
displaySearchResults(books);
} catch (error) { } catch (error) {
console.error("Search books error:", error); console.error("Search books error:", error);
showToast("Failed to search books", "error"); showToast("Failed to search books", "error");
@@ -397,7 +448,7 @@ async function searchBooks(): Promise<void> {
} }
function displaySearchResults(books: BookInfo[]): void { function displaySearchResults(books: BookInfo[]): void {
const resultsContainer = document.getElementById("search-results") as HTMLElement; const resultsContainer = document.getElementById("cs-search-results") as HTMLElement;
if (!resultsContainer) return; if (!resultsContainer) return;
if (books.length === 0) { if (books.length === 0) {
@@ -409,7 +460,8 @@ function displaySearchResults(books: BookInfo[]): void {
(book) => ` (book) => `
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer" <div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
data-book-id="${book.media_item_id}" data-book-id="${book.media_item_id}"
onclick="addBookToSelection('${book.media_item_id}', '${builderEscapeHtml(book.title)}', '${builderEscapeHtml(book.author)}')"> data-book-title="${builderEscapeHtml(book.title)}"
data-book-author="${builderEscapeHtml(book.author)}">
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" <img src="${book.cover_image_path || "/static/placeholder-book.svg"}"
alt="${builderEscapeHtml(book.title)}" alt="${builderEscapeHtml(book.title)}"
class="w-10 h-15 object-cover rounded"> class="w-10 h-15 object-cover rounded">
@@ -464,7 +516,7 @@ function updateSelectedBooksDisplay(): void {
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm" <div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
style="background-color: var(--accent);"> style="background-color: var(--accent);">
<span>${builderEscapeHtml(book.title)}</span> <span>${builderEscapeHtml(book.title)}</span>
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')" <button type="button" data-remove-book="${book.media_item_id}"
class="hover:opacity-70">×</button> class="hover:opacity-70">×</button>
</div> </div>
`, `,
@@ -627,13 +679,6 @@ function builderEscapeHtml(text: string): string {
initCustomSectionBuilder(); initCustomSectionBuilder();
// Inline onclick handlers in the generated selection HTML resolve against // Picker/selection clicks are delegated inside initCustomSectionBuilder —
// globals; bundled module functions need explicit exposure. // the CSP build refuses inline onclick= handlers, so the generated markup
declare global { // carries data attributes instead and no globals are needed.
interface Window {
addBookToSelection: typeof addBookToSelection;
removeBookFromSelection: typeof removeBookFromSelection;
}
}
window.addBookToSelection = addBookToSelection;
window.removeBookFromSelection = removeBookFromSelection;
-191
View File
@@ -1,191 +0,0 @@
import { Alpine } from "./alpine";
import { authHeaders } from "./storage";
import { showToast } from "./toast";
async function loadUnlinkedBooks(): Promise<void> {
// cookie auth carries the call; a null bearer must not strand it
try {
const response = await fetch("/api/sync/unlinked-books", {
headers: authHeaders(),
});
if (response.ok) {
const data = await response.json();
renderUnlinkedBooks(data.unlinked || []);
}
} catch (error) {
console.error("Failed to load unlinked books:", error);
}
}
function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
const container = document.getElementById("unlinked-books-list");
if (!container) return;
if (books.length === 0) {
container.innerHTML =
'<p class="text-center p-4" style="color: var(--text-secondary)">No unlinked books</p>';
return;
}
container.innerHTML = books
.map(
(book) => `
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-start">
<div>
<h3 class="font-medium" style="color: var(--text-primary)">${book.title_from_device}</h3>
<p class="text-sm" style="color: var(--text-secondary)">${book.device_name} (${book.device_type})</p>
<p class="text-xs" style="color: var(--text-secondary)">${book.file_path}</p>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Confidence: ${Math.round(book.confidence_score * 100)}%</p>
</div>
<div class="flex space-x-2">
<button onclick="window.showMatchModal('${book.progress_id}')" class="btn-primary px-3 py-1 rounded text-sm">Link</button>
</div>
</div>
${
book.potential_matches && book.potential_matches.length > 0
? `
<div class="mt-3 pt-3 border-t" style="border-color: var(--border)">
<p class="text-xs font-medium mb-2" style="color: var(--text-secondary)">Potential Matches:</p>
${book.potential_matches
.slice(0, 3)
.map(
(match) => `
<div class="flex justify-between items-center p-2 rounded mb-1" style="background-color: var(--bg-primary)">
<div>
<p class="text-sm" style="color: var(--text-primary)">${match.title}</p>
<p class="text-xs" style="color: var(--text-secondary)">${match.author} (${Math.round(match.confidence * 100)}%)</p>
</div>
<button onclick="window.linkBook('${book.progress_id}', '${match.media_item_id}')" class="btn-secondary px-2 py-1 rounded text-xs">Link</button>
</div>
`,
)
.join("")}
</div>
`
: ""
}
</div>
`,
)
.join("");
}
async function linkBook(progressId: string, mediaItemId: string): Promise<void> {
// cookie auth carries the call; a null bearer must not strand it
try {
const response = await fetch("/api/sync/link-book", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
progress_id: progressId,
media_item_id: mediaItemId,
}),
});
if (response.ok) {
showToast("Book linked successfully", "success");
loadUnlinkedBooks();
} else {
const error = await response.json();
showToast(error.error || "Failed to link book", "error");
}
} catch (error) {
console.error("Failed to link book:", error);
showToast("Failed to link book", "error");
}
}
async function autoLinkBooks(): Promise<void> {
// cookie auth carries the call; a null bearer must not strand it
if (!confirm("Auto-link all books with high confidence matches?")) return;
try {
const response = await fetch("/api/sync/auto-link", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ confidence_threshold: 0.9 }),
});
if (response.ok) {
const data = await response.json();
showToast(`Auto-linked ${data.linked_count || 0} books`, "success");
loadUnlinkedBooks();
}
} catch (error) {
console.error("Failed to auto-link:", error);
showToast("Failed to auto-link books", "error");
}
}
async function getSuggestions(progressId: string): Promise<void> {
// cookie auth carries the call; a null bearer must not strand it
try {
const response = await fetch(`/api/sync/suggestions/${progressId}`, {
headers: authHeaders(),
});
if (response.ok) {
const suggestions = await response.json();
showSuggestionsModal(progressId, suggestions);
}
} catch (error) {
console.error("Failed to get suggestions:", error);
}
}
function showSuggestionsModal(progressId: string, suggestions: PotentialMatchData[]): void {
const modal = document.getElementById("match-modal");
const content = document.getElementById("match-modal-content");
if (!modal || !content) return;
content.innerHTML = `
<div class="p-4">
<h3 class="font-medium mb-4" style="color: var(--text-primary)">Select a match</h3>
<div class="space-y-2">
${suggestions
.map(
(s) => `
<div class="p-3 rounded border cursor-pointer hover:border-opacity-50"
style="background-color: var(--bg-primary); border-color: var(--border)"
onclick="window.linkBook('${progressId}', '${s.media_item_id}'); window.hideMatchModal();">
<p class="font-medium" style="color: var(--text-primary)">${s.title}</p>
<p class="text-sm" style="color: var(--text-secondary)">${s.author}</p>
<p class="text-xs" style="color: var(--text-secondary)">${Math.round(s.confidence * 100)}% match</p>
</div>
`,
)
.join("")}
</div>
<button onclick="window.hideMatchModal()" class="mt-4 btn-secondary w-full py-2 rounded">Cancel</button>
</div>
`;
modal.classList.remove("hidden");
}
function hideMatchModal(): void {
const modal = document.getElementById("match-modal");
if (modal) {
modal.classList.add("hidden");
}
}
Alpine.data("linking", () => ({
autoLinkBooks,
getSuggestions,
hideMatchModal,
linkBook,
loadUnlinkedBooks,
showSuggestionsModal,
}));
-1
View File
@@ -20,7 +20,6 @@ import "./index";
import "./image_fallback"; import "./image_fallback";
import { armAuthFormRedirects } from "./auth_forms"; import { armAuthFormRedirects } from "./auth_forms";
import "./library-switcher"; import "./library-switcher";
import "./linking";
import "./login"; import "./login";
import "./password_validation"; import "./password_validation";
import "./processing_issues"; import "./processing_issues";
+20 -3
View File
@@ -324,7 +324,9 @@ function renderSummary(summary: Summary): void {
const mostActive = document.getElementById("stats-most-active"); const mostActive = document.getElementById("stats-most-active");
if (mostActive) { if (mostActive) {
if (summary.most_active_day) { if (summary.most_active_day) {
mostActive.innerHTML = `${summary.most_active_day} (${summary.most_active_day_minutes} min)<span class="block text-xs font-normal" style="color: ${themeVar("--text-secondary", "#999")}">${summary.most_active_weekday}</span>`; // Date and minutes on their own lines: one long line wraps mid-token
// ("(148 / min)") in the narrow stat card.
mostActive.innerHTML = `<span class="block">${summary.most_active_day}</span><span class="block">(${summary.most_active_day_minutes} min)</span><span class="block text-xs font-normal" style="color: ${themeVar("--text-secondary", "#999")}">${summary.most_active_weekday}</span>`;
} else { } else {
mostActive.textContent = "—"; mostActive.textContent = "—";
} }
@@ -510,7 +512,7 @@ function renderTopBooks(books: TopBook[]): void {
(book) => ` (book) => `
<a href="/media/${book.media_item_id}" class="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-hover"> <a href="/media/${book.media_item_id}" class="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-hover">
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)"> <span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.style.display='none'"/> <img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy"/>
</span> </span>
<span class="flex-1 min-w-0"> <span class="flex-1 min-w-0">
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(book.title)}</span> <span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(book.title)}</span>
@@ -684,7 +686,7 @@ function renderUsageItems(
<a href="/media/${item.media_item_id}" class="block p-2 rounded-lg hover:bg-surface-hover"> <a href="/media/${item.media_item_id}" class="block p-2 rounded-lg hover:bg-surface-hover">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)"> <span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
<img src="${item.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.style.display='none'"/> <img src="${item.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy"/>
</span> </span>
<span class="flex-1 min-w-0"> <span class="flex-1 min-w-0">
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(item.title)}</span> <span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(item.title)}</span>
@@ -710,6 +712,21 @@ function escapeHtml(value: string): string {
} }
function initStatsPage(): void { function initStatsPage(): void {
// Broken-cover fallback without inline onerror: the CSP build refuses
// inline handlers, so listen in capture phase on each list container
// (error does not bubble, but capture still reaches it; the containers
// persist across innerHTML swaps).
for (const containerID of ["stats-top-books", "stats-lib-longest", "stats-lib-largest"]) {
document.getElementById(containerID)?.addEventListener(
"error",
(e) => {
const img = e.target as HTMLElement;
if (img instanceof HTMLImageElement) img.style.display = "none";
},
true,
);
}
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const initial = params.get("range") || "7"; const initial = params.get("range") || "7";
currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7"; currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7";
+31
View File
@@ -618,6 +618,15 @@
} }
} }
@keyframes app-sheet-in {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@layer components { @layer components {
/* ---------- Surfaces ---------- */ /* ---------- Surfaces ---------- */
@@ -628,6 +637,28 @@
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
/* ---------- Modal shell ---------- */
/* Reusable oversized-pop-up shell (.app-modal-panel): centered dialog on
desktop (>=640px), full-height right slide-out sheet on phones. Pair
with an overlay of `flex items-stretch justify-end sm:items-center
sm:justify-center sm:p-4`. The slide-in animation replays on every
open because the overlay toggles display none -> flex. Long content
scrolls inside the panel; give the header/footer `sticky` + the card
background so actions stay reachable. */
.app-modal-panel {
@apply w-full h-dvh overflow-y-auto overscroll-contain rounded-l-2xl;
box-shadow: var(--shadow-pop);
animation: app-sheet-in 240ms cubic-bezier(0.32, 0.72, 0, 1);
}
@media (min-width: 640px) {
.app-modal-panel {
@apply h-auto max-h-[calc(100vh-2rem)] rounded-2xl;
animation: none;
}
}
/* ---------- Buttons ---------- */ /* ---------- Buttons ---------- */
.btn { .btn {