Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2165829d0e | ||
|
|
04e855e691 | ||
|
|
3e8ac3b9b7 | ||
|
|
10a5ac88da | ||
|
|
3feb608d26 | ||
|
|
9b2d3da7e2 | ||
|
|
788e9463cf | ||
|
|
ef4de7896a | ||
|
|
12aab156f6 | ||
|
|
c1a7648076 | ||
|
|
9e2f216af5 | ||
|
|
ceed3ddeae | ||
|
|
0673550d58 | ||
|
|
71b171bf72 | ||
|
|
2df6f8e55c | ||
|
|
88adf30aa9 | ||
|
|
e3ca3426ba | ||
|
|
42b283480a | ||
|
|
913174cdf8 | ||
|
|
bf4735f3ce | ||
|
|
e07301f74a |
No files matched your search
@@ -1,6 +1,14 @@
|
||||
# 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
|
||||
|
||||
This document outlines the complete plan for automatically generating screenshots for Bookhoard documentation using Playwright.
|
||||
|
||||
@@ -4,7 +4,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
- **[Development Guide](Development.md)** - Development workflow and architecture
|
||||
- **[Development Guide](development.md)** - Development workflow and architecture
|
||||
- Architecture overview
|
||||
- Directory structure
|
||||
- Local development setup
|
||||
@@ -14,7 +14,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
|
||||
|
||||
## 📋 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
|
||||
- Mandatory requirements
|
||||
- Error recovery protocol
|
||||
@@ -22,7 +22,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
|
||||
|
||||
## 🤝 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
|
||||
- Understanding the codebase
|
||||
|
||||
@@ -510,9 +510,9 @@ DEBUG=true
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [AGENTS.md](../../AGENTS.md) - Agent/development rules
|
||||
- [API Reference](../API_REFERENCE.md) - Complete API documentation
|
||||
- [Troubleshooting](../TROUBLESHOOTING.md) - Deployment issues
|
||||
- [Development rules and standards](contributing.md) - Contributing portal
|
||||
- [API Reference](../developer/api/api-reference.md) - Split endpoint docs, indexed from its overview
|
||||
- [Troubleshooting](../operations/troubleshooting.md) - Deployment issues
|
||||
- [Go Documentation](https://go.dev/doc/)
|
||||
- [Echo Framework](https://echo.labstack.com/docs)
|
||||
- [pgx Documentation](https://pgx.github.io/pgx/)
|
||||
|
||||
@@ -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 & 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 & 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"
|
||||
```
|
||||
@@ -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
|
||||
|
||||
- [User Guide: Calibre Integration](../../user/calibre-integration.md)
|
||||
- [Implementation Plan](../../CALIBRE_OPF_IMPLEMENTATION.md)
|
||||
- [Scanner Source Code](../../internal/services/media_scanner.go)
|
||||
- [User Guide: Calibre Integration](../user/calibre-integration.md)
|
||||
- The original implementation plan file was removed from the repo when this page absorbed its content.
|
||||
- The scanner implementation lives in `internal/services/` (not hyperlinked — the docs site serves only docs).
|
||||
+38
-80
@@ -13,6 +13,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
- **Device Setup**
|
||||
- [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)
|
||||
- [Android App](user/devices/android-app.md)
|
||||
|
||||
- **Sync Configuration**
|
||||
- [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
|
||||
- [Every filter field](user/filters/fields.md) - the generated, always-current reference
|
||||
|
||||
- **Frontend & Settings** (Coming Soon)
|
||||
- Frontend Guide
|
||||
- User Areas Guide
|
||||
- Settings Guide
|
||||
- Admin Guide
|
||||
- **Guides**
|
||||
- [Reader Guide](user/reader-guide.md) - reading, highlights, notes
|
||||
- [Library Browsing](user/library-browsing.md) - browsing and searching your library
|
||||
- [Collections](user/collections.md) - manual and ⚡ smart shelves, books and episodes
|
||||
- [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
|
||||
|
||||
**[Developer Documentation Portal](developer/development.md)** - Technical documentation & API reference
|
||||
|
||||
- **API Documentation**
|
||||
- [Complete API Reference](developer/api-reference.md) - Monolithic REST API reference (1,300+ lines)
|
||||
- [Split Endpoint Docs](developer/api/api-reference.md) - Individual endpoint docs
|
||||
- [Collections API](developer/api/collections/list_collections.md) - Collections management API
|
||||
- [Admin API](developer/api/admin/) - User management (admin operations)
|
||||
- [Media Items API](developer/api/media-items/) - Media item operations including bulk operations and downloads
|
||||
- [Devices API](developer/api/devices/) - Device sync and shelf management
|
||||
- [Conflicts API](developer/api/conflicts/) - Sync conflict resolution endpoints
|
||||
- [Queue API](developer/api/queue/) - Sync queue management endpoints
|
||||
- [Scanner API](developer/api/scanner/) - Library scanning and automated watch mode (admin)
|
||||
- [KOReader API](developer/api/koreader/) - KOReader sync protocol endpoints
|
||||
- [Kobo API](developer/api/kobo/) - Kobo sync protocol endpoints (feature coming soon)
|
||||
- [WebSocket API](developer/api/websocket/) - Real-time sync events
|
||||
- **API Documentation** — every endpoint is documented individually; the links below are entry points
|
||||
- [API Reference overview](developer/api/api-reference.md) - index of all endpoint docs
|
||||
- [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
|
||||
- [Devices API](developer/api/devices/list_devices.md) - Device sync and shelf management
|
||||
- [Conflicts API](developer/api/conflicts/list_conflicts.md) - Sync conflict resolution endpoints
|
||||
- [Queue API](developer/api/queue/list_all_queue_items.md) - Sync queue management endpoints
|
||||
- [Scanner API](developer/api/scanner/overview.md) - Library scanning and automated watch mode (admin)
|
||||
- [KOReader API](developer/api/koreader/get_library.md) - KOReader sync protocol endpoints
|
||||
- [Kobo API](developer/api/kobo/sync_from_server.md) - Kobo 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)
|
||||
|
||||
- **Protocol Specifications**
|
||||
- [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)
|
||||
- [WebSocket API](developer/websocket-api.md) - Real-time events
|
||||
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync
|
||||
|
||||
- **Writing UI docs?** Read [Writing UI documentation without screenshots](developer/writing-ui-docs.md) first.
|
||||
|
||||
### 🔧 For Operations
|
||||
|
||||
**[Operations Documentation Portal](operations/operations.md)** - Deployment & maintenance
|
||||
|
||||
- **Deployment**
|
||||
- [Troubleshooting Guide](operations/troubleshooting.md) - Common deployment issues and solutions
|
||||
|
||||
- **Maintenance** (Coming Soon)
|
||||
- Deployment Guide
|
||||
- Maintenance Guide
|
||||
- Monitoring Guide
|
||||
|
||||
### 🤝 For Contributors
|
||||
|
||||
**[Contributing Portal](contributing/contributing.md)** - Development workflow
|
||||
|
||||
- [Development Guide](contributing/development.md) - Architecture, setup, testing
|
||||
- [../../AGENTS.md](../../AGENTS.md) - Development rules and standards
|
||||
- [Development Guide](contributing/development.md) - Architecture, setup, testing, and the project rules
|
||||
|
||||
---
|
||||
|
||||
@@ -76,11 +74,11 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
|
||||
| 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) |
|
||||
| **Use the API** | [Developer Portal → API Docs](developer/development.md) |
|
||||
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
|
||||
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
|
||||
| **Use the API** | [API Reference overview](developer/api/api-reference.md) |
|
||||
| **Deploy Bookhoard** | [Operations → Troubleshooting](operations/troubleshooting.md) |
|
||||
| **Contribute code** | [Development Guide](contributing/development.md) |
|
||||
| **Understand sync** | [User Portal → Sync Guide](user/sync-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 |
|
||||
| --------------------------- | ------------------------------------------------------ |
|
||||
| ...install Bookhoard? | [README.md](../README.md) - Quick Start |
|
||||
| ...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 |
|
||||
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
|
||||
| ...read in the browser? | [Web Reader Guide](user/reader-guide.md) - reading, highlights, notes |
|
||||
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
||||
| ...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) |
|
||||
|
||||
### "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
|
||||
|
||||
When adding new features:
|
||||
|
||||
1. **User-facing features** → Update relevant User docs
|
||||
2. **API endpoints** → Update [API Reference](developer/api-reference.md) & split docs
|
||||
3. **Backend changes** → Update [Development Guide](contributing/development.md)
|
||||
4. **Deployment changes** → Update [Operations Portal](operations/operations.md)
|
||||
|
||||
Keep [../../AGENTS.md](../../AGENTS.md) in mind for documentation standards.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: August 2026
|
||||
**Bookhoard Version**: 1.0
|
||||
1. **User-facing features** → Update the relevant User 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 the [Development Guide](contributing/development.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)
|
||||
6. **UI appearances in docs** → Follow [Writing UI documentation without screenshots](developer/writing-ui-docs.md)
|
||||
@@ -15,19 +15,19 @@ Welcome to the Bookhoard operations documentation. This section contains guides
|
||||
|
||||
### Additional Deployment Guides
|
||||
|
||||
**[Deployment Guide](deployment.md)** - Comprehensive deployment guide
|
||||
**Deployment Guide** - Comprehensive deployment guide
|
||||
|
||||
- _Coming Soon_
|
||||
|
||||
## 🛠️ Maintenance
|
||||
|
||||
**[Maintenance Guide](maintenance.md)** - Ongoing operations and maintenance
|
||||
**Maintenance Guide** - Ongoing operations and maintenance
|
||||
|
||||
- _Coming Soon_
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
**[Monitoring Guide](monitoring.md)** - Monitoring and alerting
|
||||
**Monitoring Guide** - Monitoring and alerting
|
||||
|
||||
- _Coming Soon_
|
||||
|
||||
|
||||
@@ -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)_
|
||||
@@ -139,7 +139,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
||||
|
||||
**Steps**:
|
||||
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:
|
||||
- Correct titles and authors
|
||||
- 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**:
|
||||
1. Edit metadata in Calibre (it updates `metadata.opf`)
|
||||
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)
|
||||
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**:
|
||||
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)
|
||||
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
|
||||
|
||||
### Incorrect Metadata
|
||||
@@ -332,11 +332,10 @@ file is never touched if anything fails (temp file + atomic rename).
|
||||
- [Calibre Website](https://calibre-ebook.com/)
|
||||
- [Calibre User Manual](https://manual.calibre-ebook.com/)
|
||||
- [Calibre Forum](https://www.mobileread.com/forums/forumdisplay.php?f=166)
|
||||
- [Bookhoard GitHub](https://github.com/yourusername/bookhoard)
|
||||
|
||||
## Need Help?
|
||||
|
||||
- **Documentation**: See [User Guide](user-guide.md) for general Bookhoard usage
|
||||
- **Troubleshooting**: See [Operations Guide](../operations/operations.md) for common issues
|
||||
- **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
@@ -1,42 +1,118 @@
|
||||
# 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
|
||||
- **With filter** (`/collections/favorites?library_id=xxx`): Shows only books from the specified library
|
||||
## Creating a collection
|
||||
|
||||
## 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)
|
||||
2. Click **New Collection** (top-right) — or **Create Your First Collection** if the list is empty
|
||||
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**
|
||||
Starting from a completely empty account? The button **Create Your First
|
||||
Collection** does the same thing.
|
||||
|
||||
## 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.
|
||||
- **Delete** - Removes the collection after a confirmation prompt.
|
||||
To take items out, check them on the collection page and click
|
||||
**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
@@ -1,52 +1,62 @@
|
||||
# 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%)
|
||||
- **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
|
||||
### This Week
|
||||
|
||||
### 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
|
||||
2. Click the **Customize Dashboard** icon button
|
||||
3. Toggle the collection on or off
|
||||
4. Click "Save Changes"
|
||||
1. Pick the library in the **Library** bar (a specific library, or
|
||||
**All Libraries**) — settings are saved per library
|
||||
2. Click the **Customize Dashboard** button in the Library bar
|
||||
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")
|
||||
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"
|
||||
## Custom sections
|
||||
|
||||
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
|
||||
- **Arrow Keys**: Scroll carousels horizontally
|
||||
- **Enter**: Open selected book
|
||||
- **Tab** moves between shelves and books; **Enter** opens the focused book
|
||||
- **Arrow keys** scroll the focused shelf sideways
|
||||
- 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.
|
||||
|
||||
### Touch Gestures (Mobile)
|
||||
|
||||
- **Swipe**: Drag carousel left/right to scroll
|
||||
- **Tap**: Open book details
|
||||
*Finding your way back:* the dashboard is the page with the **Library** bar —
|
||||
the only place with the **Customize Dashboard** button.
|
||||
@@ -1,40 +1,47 @@
|
||||
# Kobo Device Setup Guide
|
||||
|
||||
> ## 🚧 Coming Soon
|
||||
>
|
||||
> Native Kobo sync is not available yet. It is actively being developed and this guide will be filled in as the feature lands.
|
||||
There are two ways to use a Kobo with Bookhoard, and they can be chosen
|
||||
independently per device:
|
||||
|
||||
## 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
|
||||
- **Bookmarks, highlights, and notes** — synced with the web and other devices
|
||||
- **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.
|
||||
Nothing about native sync takes anything away from KOReader: the two can
|
||||
coexist, and a KOReader setup keeps working either way.
|
||||
|
||||
## FAQ
|
||||
|
||||
**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?**
|
||||
A: Nothing — KOReader will keep working. Native sync simply adds another option for people who prefer stock Kobo firmware.
|
||||
**Q: Will I have to redo anything when native sync lands?**
|
||||
A: No — KOReader keeps working; native sync just adds the stock-firmware
|
||||
option.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [KOReader Setup Guide](koreader-setup.md) — works on Kobo today
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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?
|
||||
|
||||
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.)
|
||||
- Kobo devices (Clara, Aura, Nia, etc.)
|
||||
- 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
|
||||
|
||||
@@ -67,17 +75,27 @@ Before you begin, make sure you have:
|
||||
|
||||
## 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
|
||||
|
||||
1. Clone the [Bookhoard KOReader plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
||||
2. Copy it to your KOReader `plugins/` directory
|
||||
3. Restart KOReader
|
||||
Installation is a git clone straight into your KOReader `plugins/` folder —
|
||||
no download-and-copy step:
|
||||
|
||||
```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
|
||||
|
||||
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**
|
||||
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).
|
||||
|
||||
### 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)
|
||||
2. Refresh the page — your device appears under **Pending Device Registrations**
|
||||
3. Click **Approve** to connect the device
|
||||
1. In the same **Bookhoard sync** menu, tap **Register device**
|
||||
2. On your computer or phone, open Bookhoard and go to the **Devices** page
|
||||
(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)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
- **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
|
||||
- **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
|
||||
|
||||
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
|
||||
|
||||
@@ -129,7 +229,8 @@ Once your device is approved, the plugin also registers Bookhoard's OPDS catalog
|
||||
- **PDF**: Fixed-layout documents
|
||||
- **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
|
||||
|
||||
@@ -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)
|
||||
2. Make sure KOReader is connected to Wi-Fi
|
||||
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
|
||||
|
||||
@@ -161,11 +262,18 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
|
||||
|
||||
**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)
|
||||
- Verify the book appears as an unlinked book for the device and link it if needed
|
||||
- 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
|
||||
|
||||
**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`)
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||
- [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: August 2026
|
||||
**Bookhoard Version**: 1.0
|
||||
@@ -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/)_
|
||||
@@ -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
|
||||
- A **sort** dropdown (title, author, date added, page count)
|
||||
- A **Filters** button that opens the filter drawer (author, tags, series, and more)
|
||||
- **Save**, **Load**, and **Clear** buttons for filter presets
|
||||
Saved filters pair naturally with [smart collections](collections.md): a
|
||||
filter answers "what matches right now?", a smart collection keeps matching
|
||||
forever.
|
||||
|
||||
## How to Save a Filter
|
||||
## Opening a book
|
||||
|
||||
1. Navigate to the **All Books** page
|
||||
2. Click **Filters** to open the drawer, set your desired filters, and click **Apply Filters**
|
||||
3. Click the **Save** button in the toolbar
|
||||
4. Enter a name for your filter (e.g., "My Sci-Fi Books")
|
||||
5. Click **Save**
|
||||
Click any cover to open the book's detail page — description, files, your
|
||||
progress, actions to read or listen, and the places to edit metadata or fix
|
||||
what a scan guessed wrong. The detail page is the hub: series links,
|
||||
reviews, bookmarks, and the edit modal all live there.
|
||||
|
||||
## Loading Saved Filters
|
||||
## Series
|
||||
|
||||
1. Click the **Load** button to open the **Saved Filters** dropdown
|
||||
2. Click a filter's name to apply it
|
||||
3. The filter values are applied instantly, without a page reload
|
||||
A book's detail page links to its **series page** — every book in that
|
||||
series, in order, for the same library. Series are one of the fields the
|
||||
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
|
||||
2. Click the trash icon next to the filter you want to remove
|
||||
3. Confirm deletion
|
||||
|
||||
**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
|
||||
*Finding your way back:* **All Books** is the wall-of-covers page with the
|
||||
**Filters** button; the same toolbar pattern is what you'll see on the
|
||||
Podcasts page.
|
||||
@@ -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
@@ -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
|
||||
2. Select **Profile**
|
||||
3. Edit any fields in the "Account Information" section
|
||||
4. Click **Save Changes**
|
||||
5. Changes take effect immediately
|
||||
| Field | What it does |
|
||||
| --- | --- |
|
||||
| **Username** | Your login name — must stay unique |
|
||||
| **Email Address** | Must stay unique |
|
||||
| **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)
|
||||
- **Email** - Your email address (must be unique)
|
||||
- **First Name** - Optional display name
|
||||
- **Last Name** - Optional display name
|
||||
- **Theme** - Your preferred color scheme
|
||||
About the two theme doors: the profile's **Theme** select offers seven
|
||||
quick picks. The sidebar's **Appearance** panel offers the full collection —
|
||||
[Themes and Wood Paneling](themes.md) covers it, including the bookshelf
|
||||
wood textures, which are per-browser and not part of your account.
|
||||
|
||||
## 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
|
||||
2. Scroll to "Change Password" section
|
||||
3. Enter your **current password**
|
||||
4. Enter your **new password**
|
||||
5. **Confirm** your new password
|
||||
6. Click **Update Password**
|
||||
The **Danger Zone** at the bottom holds **Remove My Account**. Deletion
|
||||
removes your account and everything personal to it — profile, reading
|
||||
progress and history, device registrations, your collections — and keeps the
|
||||
libraries themselves, since books belong to the library, not to you. It
|
||||
cannot be undone.
|
||||
|
||||
### Password Requirements
|
||||
The last administrator cannot delete their own account — one admin must
|
||||
always remain.
|
||||
|
||||
- Minimum 8 characters
|
||||
- Must contain uppercase, lowercase, number, and special character
|
||||
- Must match confirmation field
|
||||
- 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.
|
||||
Administrators can also edit other users' profiles (including resetting a
|
||||
password without the current one) from the admin area; that is covered with
|
||||
the rest of the admin docs.
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Bookhoard's built-in web reader works in any modern browser — desktop or
|
||||
mobile — and keeps your position, highlights, bookmarks, and notes in sync
|
||||
with the Android app and KOReader.
|
||||
with the Android app and KOReader. Audiobooks get their own player, covered
|
||||
at the end of this guide.
|
||||
|
||||
## Opening a Book
|
||||
|
||||
@@ -79,3 +80,25 @@ to jump to it.
|
||||
Position, highlights, bookmarks, and notes sync automatically through the
|
||||
server (see the [Sync Guide](sync-guide.md)). Close the tab whenever you
|
||||
like — the reader resumes exactly where you left off on any device.
|
||||
|
||||
## 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.
|
||||
@@ -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
@@ -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)
|
||||
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)
|
||||
## Supported devices today
|
||||
|
||||
---
|
||||
| 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 |
|
||||
| ---------------- | ------------------ | --------------------------- | ---------------------------------- |
|
||||
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
|
||||
| **KOReader** | ✅ Fully Supported | Wi-Fi (Bookhoard plugin) | Kindle, Kobo, PocketBook hardware |
|
||||
**Bookmarks** — location, title, and when you made them.
|
||||
|
||||
### 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 |
|
||||
| --------------------- | ------------------------------------------------------------- |
|
||||
| **Kobo Devices** | Native sync coming soon — use KOReader on Kobo hardware today |
|
||||
| **Mobile Apps** | Android/iOS apps coming later |
|
||||
## Conflicts
|
||||
|
||||
### 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 |
|
||||
| --------------------- | ---------------------------------------- |
|
||||
| **Kindle Devices** | Under consideration (no date yet) |
|
||||
| **Remarkable Tablet** | Under consideration (no date yet) |
|
||||
- The book's detail page grows a **Sync Progress** button (with a
|
||||
"Progress conflict detected" notice)
|
||||
- The **Conflicts** page (`/conflicts`) lists everything unresolved
|
||||
|
||||
---
|
||||
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
|
||||
2. **Network Connection** - Devices must be able to reach your Bookhoard server
|
||||
3. **User Account** - Created and logged in to Bookhoard web interface
|
||||
## Reading history
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Set up your first device** - See [Device Setup](#device-setup) below
|
||||
2. **Start reading** - Open any book in the web interface or on your device
|
||||
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
|
||||
|
||||
---
|
||||
Every reading session is logged — when, how long, on which device — and
|
||||
feeds your [dashboard](dashboard.md) and stats. Your history is visible on
|
||||
the **Progress** page and on each book's detail page, and it is yours
|
||||
alone.
|
||||
|
||||
## 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)
|
||||
2. Verify sync is enabled for the device
|
||||
3. Check sync URL is correct
|
||||
4. Ensure device has network connection
|
||||
5. Try manual sync: Open book → Menu → Sync Now
|
||||
**High battery or data use**
|
||||
That is the cost of continuous sync on a chatty device; e-reader plugins
|
||||
let you space out their sync checks from the plugin's own settings.
|
||||
|
||||
### "Device Not Found" Error
|
||||
## Device limits
|
||||
|
||||
**Cause**: Device not registered or authorization revoked
|
||||
|
||||
**Solutions**:
|
||||
|
||||
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
|
||||
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
|
||||
slot.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Optimal Performance
|
||||
|
||||
✅ **DO**:
|
||||
|
||||
- Use checkpoint mode when on slow connections
|
||||
- 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
|
||||
More detail on any device lives in its guide:
|
||||
[Android](devices/android-app.md) ·
|
||||
[KOReader](devices/koreader-setup.md) ·
|
||||
[Kobo](devices/kobo-setup.md) ·
|
||||
[Calibre import](calibre-integration.md) ·
|
||||
and the [filter rules](filters/index.md) that power matching-adjacent
|
||||
shelves like [smart collections](collections.md).
|
||||
+8
-2
@@ -34,7 +34,7 @@ full set of [Omarchy](https://omarchy.org) defaults alongside the classics:
|
||||
|
||||
**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
|
||||
- **Solarized Light** - Precision contrast on warm paper
|
||||
- **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)
|
||||
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
|
||||
active theme is marked with a checkmark
|
||||
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.
|
||||
|
||||
## Wood Paneling
|
||||
|
||||
@@ -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
@@ -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
|
||||
- Installing and upgrading the APK
|
||||
- Connecting to a self-hosted server over LAN
|
||||
- The Android 16+ local-network permission
|
||||
- Troubleshooting login failures
|
||||
## Where to go
|
||||
|
||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
|
||||
- Installation on Kindle/Kobo/PocketBook
|
||||
- Plugin setup with server-side device approval
|
||||
- Progress, bookmark, highlight, and note sync
|
||||
- OPDS catalog access
|
||||
- Troubleshooting
|
||||
| You want to... | Read |
|
||||
| --- | --- |
|
||||
| Learn the layout: dashboard, shelves, customizing | [Dashboard](dashboard.md) |
|
||||
| Browse, search, and sort your books | [Browsing your library](library-browsing.md) |
|
||||
| Build shelves — by hand or with rules | [Collections](collections.md) |
|
||||
| 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)
|
||||
- In the meantime, KOReader works great on Kobo hardware
|
||||
New here? The usual path is: browse [your library](library-browsing.md),
|
||||
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
|
||||
- What is Universal Sync?
|
||||
- Supported devices
|
||||
- Book matching and auto-linking
|
||||
- Conflict resolution
|
||||
- Best practices
|
||||
Some screens (user management, server settings, library folders) are admin
|
||||
territory; [Admin Guide](admin-guide.md) is the door, and the admin docs are
|
||||
being expanded separately.
|
||||
|
||||
## 📚 Calibre Integration
|
||||
|
||||
- **[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)
|
||||
**Looking for developer documentation?** See the
|
||||
[Developer Portal](../developer/development.md).
|
||||
+29
-14
@@ -8,40 +8,55 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDocsRelativeLinks keeps the developer docs tree internally navigable:
|
||||
// every relative markdown link must point at a file that exists. The
|
||||
// api-reference overview once shipped 20+ directory-style links
|
||||
// (authentication/ etc.) that 404'd — this gate makes that class of rot
|
||||
// fail make test. (docs/user is excluded: end-user prose has its own pass.)
|
||||
// TestDocsRelativeLinks keeps every doc tree internally navigable: every
|
||||
// relative markdown link must point at a file that exists. The api-reference
|
||||
// overview once shipped 20+ directory-style links (authentication/ etc.)
|
||||
// that 404'd — this gate makes that class of rot fail make test.
|
||||
//
|
||||
// Rules:
|
||||
// - http(s):, mailto:, and pure #anchors are skipped
|
||||
// - 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
|
||||
// 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]+)\)`)
|
||||
|
||||
func TestDocsRelativeLinks(t *testing.T) {
|
||||
// Walk developer docs; escape-check against the whole docs tree —
|
||||
// cross-area links into docs/user are legitimate (same site).
|
||||
devRoot, err := filepath.Abs("../../docs/developer")
|
||||
if err != nil {
|
||||
t.Fatalf("abs developer root: %v", err)
|
||||
var handsOffFilterDocs = []string{
|
||||
"user/filters/",
|
||||
"user/searching-and-filtering.md",
|
||||
}
|
||||
|
||||
func TestDocsRelativeLinks(t *testing.T) {
|
||||
treeRoot, err := filepath.Abs("../../docs")
|
||||
if err != nil {
|
||||
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") {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(docsRoot, path)
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,8 +124,10 @@ type UserProfile struct {
|
||||
Role string `json:"role"`
|
||||
Theme string `json:"theme,omitempty"`
|
||||
// User-level default audiobook playback speed (plan §8); 0 omits.
|
||||
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
|
||||
// Whether the web reader/player should hold a Screen Wake Lock.
|
||||
// Response-only struct: omit nothing — default_audio_speed is NOT NULL
|
||||
// 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"`
|
||||
// Resume rewind: seconds before the saved position the audio
|
||||
// player starts (open-resume and every genuine unpause). 0 is a
|
||||
|
||||
@@ -41,10 +41,13 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
|
||||
// the component and asserts the hash matches.
|
||||
//
|
||||
// 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
|
||||
// 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("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
|
||||
if item.Author != "" {
|
||||
<p style="color: var(--text-secondary)">{ item.Author }</p>
|
||||
}
|
||||
<p class="font-mono text-xs break-all" style="color: var(--text-secondary)">{ item.FilePath }</p>
|
||||
<p class="font-mono text-xs break-all line-clamp-3" style="color: var(--text-secondary)">{ item.FilePath }</p>
|
||||
</td>
|
||||
<td class="px-4 py-3" style="color: var(--text-primary)">{ item.LibraryName }</td>
|
||||
<td class="px-4 py-3" style="color: var(--text-secondary)">{ item.Status }</td>
|
||||
|
||||
@@ -164,14 +164,14 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -49,7 +49,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
||||
unless purged; items return automatically if their files come back.
|
||||
</p>
|
||||
</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>
|
||||
<button type="button" @click="purgeArchivedItems()" class="btn btn-secondary">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
@@ -200,8 +200,13 @@ templ LibraryList(user User, libraries []LibraryData, users []User) {
|
||||
<div class="space-y-4">
|
||||
for _, library := range libraries {
|
||||
<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">
|
||||
<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
|
||||
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);">
|
||||
@Icon("library", "h-5 w-5")
|
||||
</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>
|
||||
}
|
||||
</div>
|
||||
<span class="chip shrink-0">
|
||||
<span class="chip shrink-0 hidden sm:inline-flex">
|
||||
@Icon("tag", "h-3 w-3")
|
||||
{ library.TypeName }
|
||||
</span>
|
||||
if library.FolderCount > 0 {
|
||||
<span class="chip shrink-0">
|
||||
<span class="chip shrink-0 hidden sm:inline-flex">
|
||||
@Icon("folder", "h-3 w-3")
|
||||
{ library.FolderCount } folders
|
||||
</span>
|
||||
|
||||
@@ -95,7 +95,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -288,7 +288,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -303,7 +303,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -321,7 +321,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -332,7 +332,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -343,7 +343,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -354,7 +354,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -365,7 +365,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -383,7 +383,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -404,7 +404,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -425,7 +425,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -500,7 +500,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -513,7 +513,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/scan")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -526,7 +526,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderScan(folder.FolderPath))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -547,7 +547,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -560,7 +560,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -573,7 +573,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -604,7 +604,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -617,7 +617,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -630,7 +630,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -643,7 +643,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -656,7 +656,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -713,7 +713,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -726,7 +726,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -739,7 +739,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -752,7 +752,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -765,7 +765,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
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
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -820,7 +820,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -847,7 +847,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -860,7 +860,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -873,7 +873,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -894,7 +894,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -907,7 +907,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -965,7 +965,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -983,7 +983,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1014,7 +1014,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1035,7 +1035,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1053,7 +1053,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1066,7 +1066,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -48,7 +48,7 @@ templ AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, tot
|
||||
if len(feeds) == 0 {
|
||||
<div class="card p-6 text-center text-sm" style="color: var(--text-secondary)">No podcast feeds exist yet.</div>
|
||||
} else {
|
||||
<div class="oc-table card">
|
||||
<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);">
|
||||
|
||||
@@ -155,7 +155,7 @@ func AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, tota
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -514,7 +514,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -615,7 +615,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -659,8 +659,8 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</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">
|
||||
<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"
|
||||
|
||||
@@ -1477,7 +1477,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<input type="hidden" name="resource_type" value="media-items"/>
|
||||
</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="submit" class="btn btn-secondary">Save Filter</button>
|
||||
<!-- Item 3.5: the bridge — the current tree becomes a live
|
||||
|
||||
@@ -562,7 +562,7 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package templates
|
||||
|
||||
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 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 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);">
|
||||
@Icon("folder", "h-5 w-5")
|
||||
@@ -85,7 +85,7 @@ templ CollectionModal(collection CollectionData) {
|
||||
</label>
|
||||
</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="submit" class="btn btn-primary">Update Collection</button>
|
||||
</div>
|
||||
@@ -185,7 +185,7 @@ templ CollectionModal(collection CollectionData) {
|
||||
</div>
|
||||
<input type="hidden" name="color" id="collection-color" value="blue"/>
|
||||
</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="submit" class="btn btn-primary">Create Collection</button>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<div>
|
||||
<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>
|
||||
if scope == "episodes" {
|
||||
Episodes scope — membership recomputes live from the tree below.
|
||||
|
||||
@@ -152,7 +152,7 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -60,15 +60,15 @@ templ ConflictsListContent(user User, conflicts []handlers.ConflictDetailRespons
|
||||
templ ConflictStatsBar(total int, unresolved int) {
|
||||
<div class="stat-card text-center py-3">
|
||||
<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 class="stat-card text-center py-3">
|
||||
<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 class="stat-card text-center py-3">
|
||||
<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>
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ func ConflictStatsBar(total int, unresolved int) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -232,7 +232,7 @@ func ConflictStatsBar(total int, unresolved int) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -245,7 +245,7 @@ func ConflictStatsBar(total int, unresolved int) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
||||
</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"
|
||||
style="background-color: var(--bg-primary);"
|
||||
></div>
|
||||
|
||||
@@ -143,7 +143,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+12
-12
@@ -70,7 +70,7 @@ templ CollectionCarousel(section handlers.SectionData) {
|
||||
if section.ViewAllURL != "" {
|
||||
<a
|
||||
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);"
|
||||
>
|
||||
View All
|
||||
@@ -220,11 +220,11 @@ templ BookCard(item handlers.BookInfo) {
|
||||
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
|
||||
<div
|
||||
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);"
|
||||
>
|
||||
<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 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>
|
||||
@@ -237,22 +237,22 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
||||
<div id="collection-list" class="space-y-2 mb-6">
|
||||
for _, section := range sections {
|
||||
<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-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
||||
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")
|
||||
<span class="text-xl">{ section.Icon }</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium" style="color: var(--text-primary)">{ section.Title }</span>
|
||||
<span class="text-xl shrink-0">{ section.Icon }</span>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="font-medium truncate" style="color: var(--text-primary)">{ section.Title }</span>
|
||||
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 class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-3 shrink-0 ml-auto">
|
||||
if section.IsSystem {
|
||||
<button
|
||||
data-action="restore-system-collection"
|
||||
@@ -296,7 +296,7 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
||||
target="items-count-display"
|
||||
/>
|
||||
</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="save-dashboard-settings" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
|
||||
@@ -256,7 +256,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -747,7 +747,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
templ_7745c5c3_Var34 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -760,7 +760,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -786,7 +786,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -794,27 +794,27 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -825,12 +825,12 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -907,7 +907,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -120,8 +120,11 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
<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>
|
||||
<!-- Order override (D5): per-user; refreshes the episode list.
|
||||
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
|
||||
id="podcast-order-select"
|
||||
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="newest" selected?={ data.Order == "newest" }>Newest first (pub date)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Serial suggestion (D5, §0 item 3 rename): one-click,
|
||||
never silent. "reverse" sorts by FEED POSITION DESC —
|
||||
@@ -152,8 +156,9 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Auto-download policy (D6) -->
|
||||
<label class="text-sm ml-2" for="podcast-autodl-select" style="color: var(--text-secondary)">Auto-download</label>
|
||||
<!-- 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>
|
||||
@PodcastAutoDownloadSelect(data.Feed)
|
||||
if data.Feed.AutoDownload == "newest" {
|
||||
<input
|
||||
@@ -167,6 +172,7 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
aria-label="Newest N episodes"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Unsubscribe (issues #4/#7): the sole subscriber who is
|
||||
an admin gets the three-choice modal (unsubscribe /
|
||||
|
||||
@@ -359,14 +359,14 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/order")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -412,7 +412,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -424,7 +424,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/order")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -435,7 +435,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -451,7 +451,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(podcastInt(data.Feed.AutoDownloadCount))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -462,7 +462,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -479,7 +479,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/subscription")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -497,7 +497,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/subscription")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -516,7 +516,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -555,7 +555,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
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")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -578,7 +578,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/episodes/delete-files")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -651,7 +651,7 @@ func PodcastEpisodeList(data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("/podcasts/" + data.Feed.ID + "/episodes")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -721,7 +721,7 @@ func PodcastEpisodeListPolling(data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue("/podcasts/" + data.Feed.ID + "/episodes")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -773,7 +773,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolAttr(ep.Downloaded))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -786,7 +786,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(podcastPct(ep.ListenPct))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -799,7 +799,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(ep.Title + " " + ep.Description))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -818,7 +818,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(ep.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -831,7 +831,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("Select " + ep.Title + " for file deletion")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -855,7 +855,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(ep.Position))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -868,7 +868,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var37 templ.SafeURL
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(ep.PlayURL)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -881,7 +881,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Title)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -899,7 +899,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -917,7 +917,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -935,7 +935,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -958,7 +958,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(ep.DownloadError)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -982,7 +982,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(progressWidth(ep.ProgressPct) + " background-color: var(--accent);")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -995,7 +995,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1013,7 +1013,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1036,7 +1036,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Date)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1050,7 +1050,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var47 string
|
||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Duration)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1065,7 +1065,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var48 string
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Size)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1084,7 +1084,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var49 templ.SafeURL
|
||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(ep.PlayURL)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1145,7 +1145,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var52 string
|
||||
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + ep.FeedID + "/episodes/" + ep.ID + "/download")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1158,7 +1158,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var53 string
|
||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(downloadButtonTitle(ep.DownloadState))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1179,7 +1179,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var54 string
|
||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(downloadButtonLabel(ep.DownloadState))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1258,7 +1258,7 @@ func PodcastAutoDownloadSelect(feed PodcastFeedCard) templ.Component {
|
||||
var templ_7745c5c3_Var56 string
|
||||
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + feed.ID + "/subscription")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1334,7 +1334,7 @@ func PodcastOrderSelectOOB(feedID string, order string) templ.Component {
|
||||
var templ_7745c5c3_Var58 string
|
||||
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + feedID + "/order")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1418,7 +1418,7 @@ func PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) templ.Component {
|
||||
var templ_7745c5c3_Var60 string
|
||||
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + feed.ID + "/subscription")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1494,7 +1494,7 @@ func PodcastUnsubscribeModal(data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var62 string
|
||||
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.DisplayTitle())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1507,7 +1507,7 @@ func PodcastUnsubscribeModal(data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var63 string
|
||||
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/subscription")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1520,7 +1520,7 @@ func PodcastUnsubscribeModal(data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var64 string
|
||||
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -104,9 +104,9 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
|
||||
</div>
|
||||
}
|
||||
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")
|
||||
<span>CFI: { item.EpubCFI }</span>
|
||||
<span class="min-w-0 break-all">CFI: { item.EpubCFI }</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -308,7 +308,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -316,14 +316,14 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -29,7 +29,7 @@ templ Stats(user User) {
|
||||
<!-- 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">
|
||||
<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>
|
||||
|
||||
@@ -80,7 +80,7 @@ func Stats(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
Two specs added against a RUNNING dev server (skip unless
|
||||
|
||||
@@ -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
@@ -25,6 +25,7 @@ function hide(ok: boolean): void {
|
||||
* Safe to call from any module (import this instead of window.confirm).
|
||||
*/
|
||||
export function confirmDialog(message: string): Promise<boolean> {
|
||||
wireButtons();
|
||||
const { overlay, message: messageEl } = modalElements();
|
||||
if (!overlay || !messageEl) {
|
||||
// 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 {
|
||||
const { overlay } = modalElements();
|
||||
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
|
||||
// modal instead of the browser dialog; confirming issues the request.
|
||||
// (Canonical htmx pattern — see htmx docs, "confirming with a modal".)
|
||||
|
||||
@@ -257,6 +257,27 @@ function initCustomSectionBuilder(): void {
|
||||
if (form) {
|
||||
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 {
|
||||
@@ -363,7 +384,11 @@ function onBookSearchInput(): void {
|
||||
async function searchBooks(): Promise<void> {
|
||||
const searchInput = document.getElementById("book-search") as HTMLInputElement;
|
||||
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 libraryId = librarySelect?.value;
|
||||
@@ -374,22 +399,48 @@ async function searchBooks(): Promise<void> {
|
||||
}
|
||||
|
||||
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(
|
||||
`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`,
|
||||
`/api/media-items/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`,
|
||||
{
|
||||
headers: {
|
||||
...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) {
|
||||
throw new Error("Failed to search books");
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error("Search books error:", error);
|
||||
showToast("Failed to search books", "error");
|
||||
@@ -397,7 +448,7 @@ async function searchBooks(): Promise<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 (books.length === 0) {
|
||||
@@ -409,7 +460,8 @@ function displaySearchResults(books: BookInfo[]): void {
|
||||
(book) => `
|
||||
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
|
||||
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"}"
|
||||
alt="${builderEscapeHtml(book.title)}"
|
||||
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"
|
||||
style="background-color: var(--accent);">
|
||||
<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>
|
||||
</div>
|
||||
`,
|
||||
@@ -627,13 +679,6 @@ function builderEscapeHtml(text: string): string {
|
||||
|
||||
initCustomSectionBuilder();
|
||||
|
||||
// Inline onclick handlers in the generated selection HTML resolve against
|
||||
// globals; bundled module functions need explicit exposure.
|
||||
declare global {
|
||||
interface Window {
|
||||
addBookToSelection: typeof addBookToSelection;
|
||||
removeBookFromSelection: typeof removeBookFromSelection;
|
||||
}
|
||||
}
|
||||
window.addBookToSelection = addBookToSelection;
|
||||
window.removeBookFromSelection = removeBookFromSelection;
|
||||
// Picker/selection clicks are delegated inside initCustomSectionBuilder —
|
||||
// the CSP build refuses inline onclick= handlers, so the generated markup
|
||||
// carries data attributes instead and no globals are needed.
|
||||
@@ -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,
|
||||
}));
|
||||
@@ -20,7 +20,6 @@ import "./index";
|
||||
import "./image_fallback";
|
||||
import { armAuthFormRedirects } from "./auth_forms";
|
||||
import "./library-switcher";
|
||||
import "./linking";
|
||||
import "./login";
|
||||
import "./password_validation";
|
||||
import "./processing_issues";
|
||||
|
||||
+20
-3
@@ -324,7 +324,9 @@ function renderSummary(summary: Summary): void {
|
||||
const mostActive = document.getElementById("stats-most-active");
|
||||
if (mostActive) {
|
||||
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 {
|
||||
mostActive.textContent = "—";
|
||||
}
|
||||
@@ -510,7 +512,7 @@ function renderTopBooks(books: TopBook[]): void {
|
||||
(book) => `
|
||||
<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)">
|
||||
<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 class="flex-1 min-w-0">
|
||||
<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">
|
||||
<div class="flex items-center gap-3">
|
||||
<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 class="flex-1 min-w-0">
|
||||
<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 {
|
||||
// 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 initial = params.get("range") || "7";
|
||||
currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7";
|
||||
|
||||
@@ -618,6 +618,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-sheet-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ---------- Surfaces ---------- */
|
||||
|
||||
@@ -628,6 +637,28 @@
|
||||
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 ---------- */
|
||||
|
||||
.btn {
|
||||
|
||||
Reference in new issue
Block a user