Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Known behavior (unchanged or deliberate): the bar is sticky, so stats
stay visible while scrolling; a library switch re-renders the details
closed; scopes with no current-week reading show zeros (e.g. Manga).
2026-10-27 10:22:13 -04:00
39 changed files with 1694 additions and 601 deletions

No files matched your search

+26
View File
@@ -4,6 +4,32 @@ Standing rules for any agent or developer working in this repo.
Project-wide context lives in the sibling repos' AGENTS.md files
(../bookhoard-app/AGENTS.md is the Android client's).
## Commit as each unit of work completes — ALWAYS apply
Commit when a logically complete piece of the project is done — a fix,
a feature slice, a schema change, a docs/test batch — before moving on
to the next piece. Do not hold a whole session's work for one giant
end-of-session commit, and never push uncommitted work aside until the
tree is clean.
- **One commit per related edit.** If the changed files serve more than
one purpose, they are separate commits; only share a commit when you
are sure every file in it is the same related edit. Generated
artifacts (templ codegen, sqlc output, vite/tailwind build output,
bruno/openapi mirrors) commit with the source change that produced
them.
- **Detailed messages**, in the repo's conventional-commit style
(`fix(scanner): …`, `feat(issues): …`) with a body that says WHY the
change exists, what behavior changed, and any precedent it follows —
not just what the diff shows. The body is how the next agent (and
git history) arbitrates "test stale vs code changed".
- **Ordering**: stack commits so each tree compiles where practical
(a service before the handler that calls it, a guard before the
feature that relies on it).
- **Committing ≠ pushing.** Commits are local bookkeeping and land as
work completes; pushing (once, after the last commit) is the author's
call. Never push half-finished units just because they were committed.
## Rendering ownership (templ vs TypeScript) — ALWAYS apply
The point of templ + htmx + Alpine is: the server renders the UI, and
@@ -0,0 +1,23 @@
info:
name: Retry Processing Issue
type: http
seq: 18
http:
method: POST
url: '{{base_url}}/api/libraries/{{ebook_library_id}}/issues/{{issueid}}/retry'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Retry Processing Issue
`POST /api/libraries/:id/issues/:issueId/retry` — auth: jwt+admin.
Canonical reference: docs/developer/api/libraries/retry_processing_issue.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Scan Library
type: http
seq: 18
seq: 19
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Set Library Visibility
type: http
seq: 19
seq: 20
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Update Library
type: http
seq: 20
seq: 21
http:
method: PUT
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Update Scan Settings
type: http
seq: 21
seq: 22
http:
method: PUT
@@ -0,0 +1,213 @@
package main
// Retry + scoped-scan integration tests (2026-10 Processing Issues work).
//
// - Per-issue retry re-imports a row-less import_failed ledger entry
// through the individual scanner (NOT a library scan) and closes it.
// - A missing file retries to 409 with the real reason.
// - An item-backed issue retries through the book-detail rescan
// mechanism and closes.
// - POST /api/libraries/:id/scan with folder_path must be a registered
// folder (validated, never trusted).
// - A single-folder scan must NOT run archive reconciliation for the
// library's other folders — without that guard the scoped walk would
// mark every item under the unscanned folders missing and archive them.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"bookhoard/internal/database"
)
func retryIssueViaAPI(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, issueID uuid.UUID) *httptest.ResponseRecorder {
t.Helper()
libUUID, err := uuid.FromBytes(libraryID.Bytes[:])
require.NoError(t, err)
req := httptest.NewRequest("POST", "/api/libraries/"+libUUID.String()+"/issues/"+issueID.String()+"/retry", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
return rec
}
func openIssuesFor(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID) []database.ListProcessingIssuesByLibraryRow {
t.Helper()
rows, err := setup.DB.ListProcessingIssuesByLibrary(context.Background(), libraryID)
require.NoError(t, err)
return rows
}
func seedRowlessIssue(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, relPath string) {
t.Helper()
_, err := setup.DBPool.Exec(context.Background(),
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
VALUES (NULL, $1, 'import_failed', 'seeded failure for retry test', 'error', $2)`,
libraryID, relPath)
require.NoError(t, err)
}
func TestRetryProcessingIssueRowless(t *testing.T) {
setup := setupTestServer(t)
defer setup.Close()
admin, _ := moveTestAdminID(t, setup)
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Retry Rowless %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// A real file whose import "failed" earlier (the ledger row exists,
// the media_items row does not) — exactly the production shape the
// retry button targets.
rel := "Author/retrybook.epub"
require.NoError(t, os.MkdirAll(filepath.Join(folder, "Author"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(folder, rel), []byte("dummy epub content"), 0o644))
seedRowlessIssue(t, setup, libID, filepath.ToSlash(rel))
issues := openIssuesFor(t, setup, libID)
require.Len(t, issues, 1)
issueID, err := uuid.FromBytes(issues[0].ID.Bytes[:])
require.NoError(t, err)
rec := retryIssueViaAPI(t, setup, libID, issueID)
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
// The file imported through the individual scanner...
var count int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT count(*) FROM media_items WHERE library_id = $1 AND file_path = $2`,
libID, filepath.ToSlash(rel)).Scan(&count))
require.Equal(t, 1, count, "retry must import the file")
// ...and the ledger closed (import self-heal + explicit path resolve).
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT count(*) FROM processing_issues WHERE id = $1 AND resolved AND resolved_at IS NOT NULL`,
issues[0].ID).Scan(&count))
require.Equal(t, 1, count, "successful retry must resolve the issue")
require.Len(t, openIssuesFor(t, setup, libID), 0)
// Missing file: retry answers 409 with the real reason, issue stays.
seedRowlessIssue(t, setup, libID, "Author/vanished.epub")
issues = openIssuesFor(t, setup, libID)
require.Len(t, issues, 1)
vanishedID, err := uuid.FromBytes(issues[0].ID.Bytes[:])
require.NoError(t, err)
rec = retryIssueViaAPI(t, setup, libID, vanishedID)
require.Equal(t, http.StatusConflict, rec.Code)
assert.Contains(t, rec.Body.String(), "not found on disk")
// Item-backed issue: retry runs the book-detail rescan mechanism.
itemRow, err := setup.DB.GetMediaItemByLibraryAndPath(context.Background(),
database.GetMediaItemByLibraryAndPathParams{LibraryID: libID, FilePath: filepath.ToSlash(rel)})
require.NoError(t, err)
_, err = setup.DBPool.Exec(context.Background(),
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity)
VALUES ($1, $2, 'metadata_extraction_failed', 'seeded item issue for retry test', 'warning')`,
itemRow.ID, libID)
require.NoError(t, err)
// vanished.epub's issue is still open — pick the item-backed one.
var itemIssueID uuid.UUID
for _, open := range openIssuesFor(t, setup, libID) {
if open.MediaItemID.Valid {
itemIssueID, err = uuid.FromBytes(open.ID.Bytes[:])
require.NoError(t, err)
break
}
}
require.NotEqual(t, uuid.Nil, itemIssueID, "item-backed fixture")
rec = retryIssueViaAPI(t, setup, libID, itemIssueID)
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
var resolvedCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT count(*) FROM processing_issues WHERE id = $1 AND resolved`,
itemIssueID).Scan(&resolvedCount))
require.Equal(t, 1, resolvedCount, "successful item retry must resolve the issue")
_ = admin // scanner attribution is implicit via the library owner
}
func TestScanLibraryFolderPathValidation(t *testing.T) {
setup := setupTestServer(t)
defer setup.Close()
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Scan Validate %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
post := func(folderPath string) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]string{"folder_path": folderPath})
req := httptest.NewRequest("POST", "/api/libraries/"+libID.String()+"/scan", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
return rec
}
// An unregistered path is rejected, never scanned.
rec := post("/tmp/definitely-not-registered")
require.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "not one of this library's registered folders")
// A registered folder scopes the scan and enqueues normally.
rec = post(folder)
require.Equal(t, http.StatusAccepted, rec.Code, "body: %s", rec.Body.String())
}
func TestScanSingleFolderSkipsArchiveReconciliation(t *testing.T) {
setup := setupTestServer(t)
defer setup.Close()
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folderA := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Archive Guard %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// Second registered folder: the scoped scan below covers only this one.
folderB := t.TempDir()
_, err := setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
LibraryID: libID,
FolderPath: folderB,
})
require.NoError(t, err)
// A healthy item under folder A.
rel := "Author/archguard.epub"
require.NoError(t, os.MkdirAll(filepath.Join(folderA, "Author"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(folderA, rel), []byte("dummy epub content"), 0o644))
scanTestFolder(t, setup, admin, folderA)
item, err := setup.DB.GetMediaItemByLibraryAndPath(ctx,
database.GetMediaItemByLibraryAndPathParams{LibraryID: libID, FilePath: filepath.ToSlash(rel)})
require.NoError(t, err)
require.False(t, item.ArchivedAt.Valid)
require.Equal(t, int32(0), item.MissingScanCount)
// Scan ONLY folder B. Without the partial-scope guard the archive
// pass would see nothing from folder A and mark this item missing
// (1/2 toward archival) — the exact false-archive the guard exists
// to prevent.
scanTestFolder(t, setup, admin, folderB)
after, err := setup.DB.GetMediaItem(ctx, item.ID)
require.NoError(t, err)
require.False(t, after.ArchivedAt.Valid, "scoped scan must not archive items under unscanned folders")
require.Equal(t, int32(0), after.MissingScanCount, "scoped scan must not mark unscanned-folder items missing")
// Control: the item's own folder scans clean as before.
scanTestFolder(t, setup, admin, folderA)
after, err = setup.DB.GetMediaItem(ctx, item.ID)
require.NoError(t, err)
require.False(t, after.ArchivedAt.Valid)
require.Equal(t, int32(0), after.MissingScanCount)
}
+9 -1
View File
@@ -174,7 +174,7 @@ CREATE TABLE IF NOT EXISTS media_items (
language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1)
edition VARCHAR(100), -- Edition information
page_count INTEGER, -- Total page count
genre VARCHAR(100), -- Genre classification
genre TEXT, -- Genre classification (smart-genre shelf OR raw tag free text — Audible category strings exceed 100 chars)
copyright_year INTEGER, -- Copyright/publication year
goodreads_id VARCHAR(50), -- Goodreads identifier
openlibrary_id VARCHAR(50), -- Open Library identifier
@@ -279,6 +279,14 @@ CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
-- Widen genre for existing databases: the audio tag fallback stores the
-- raw file genre atom (Audible category strings exceed 100 chars —
-- e.g. "Politicians, Presidents & Heads of State, United States, ..."),
-- and VARCHAR(100) rejected the whole import/update at the database
-- layer (SQLSTATE 22001). VARCHAR -> TEXT is a metadata-only change,
-- safe to re-run (media_highlights position widening precedent).
ALTER TABLE media_items ALTER COLUMN genre TYPE TEXT;
-- Audiobook editorial facts (A2a): narrators are user-editable first-class
-- (search/filter/badge surface); abridged rides along; audio_metadata keeps
-- the scan-derived tech record (duration/codec/chapters).
@@ -0,0 +1,41 @@
---
method: POST
path: /api/libraries/:id/issues/:issueId/retry
auth: jwt+admin
area: libraries
---
<!-- source: internal/handlers/processing_issues.go RetryProcessingIssue -->
# Retry Processing Issue
Re-runs the work that produced an open issue through the individual
scanner — deliberately NOT a library-wide rescan. Two shapes:
- **Item-backed issues** (media item exists): the item is rescanned via
the same mechanism as the book-detail Rescan action — self-resolving
folders, multi-file audiobook routing, write-after-success guards.
- **Row-less `import_failed` issues** (no media item): the single file
is re-imported through the scanner's standalone per-file path.
On success the issue is closed. On failure the response carries the REAL
error (e.g. the underlying SQL failure), so the UI can show why the issue
still exists. The issue's library must be in the requester's visible set.
## Example response
```json example-response
{
"success": true,
"message": "File imported; issue resolved"
}
```
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "Invalid issue ID"}` | malformed path param |
| 403 | — | issue's library not in requester's visible set |
| 404 | `{"error": "Issue not found"}` | no such issue |
| 409 | `{"error": "Issue is already resolved"}` / `{"error": "media file not found on disk: …"}` | nothing to retry, or the file vanished |
| 500 | `{"error": "…"}` | the retried work failed — body carries the underlying error |
+9 -2
View File
@@ -8,10 +8,17 @@ area: libraries
# Scan Library
Enqueues a full scan of one library's configured folders. The library id comes
Enqueues a scan of one library's configured folders. The library id comes
from the path — no request body is needed (the generic `POST /api/scanner/scan`
variant is the one that takes `folder_paths`).
**Per-folder scope**: an optional body of `{"folder_path": "…"}` restricts the
scan to ONE of the library's registered folders (the per-folder Scan buttons
in the library panel). The path must be a registered folder — anything else is
a **400**. A scoped scan never runs archive reconciliation for the library's
other folders, so items outside the scanned folder cannot be marked missing
by it.
Scans are **coalesced**: if a scan for this library is already pending or
running you get **409** with the active job id instead of a second scan.
@@ -29,7 +36,7 @@ running you get **409** with the active job id instead of a second scan.
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` | malformed id / empty library |
| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` / `{"error": "folder_path is not one of this library's registered folders"}` | malformed id / empty library / unregistered folder scope |
| 404 | `{"error": "library not found or has no folders"}` | unknown library |
| 409 | `{"error": "a scan for this library is already pending or running", "active_job_id": "..."}` | scan coalescing |
| 500 | `{"error": "failed to enqueue scan job: ..."}` | queue failure |
+2 -1
View File
@@ -35,7 +35,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
"username": "john",
"first_name": "John",
"last_name": "Doe",
"role": "user"
"role": "user",
"keep_screen_awake": true
}
```
+10 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Bookhoard API
version: '2026.10.24'
version: '2026.10.27'
description: |
Generated from the canonical API docs (docs/developer/api/**) by
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
@@ -1141,6 +1141,15 @@ paths:
responses:
'200':
description: Success
"/api/libraries/{id}/issues/{issueId}/retry":
post:
tags: [libraries]
summary: "Retry Processing Issue"
security:
- bearerAuth: []
responses:
'200':
description: Success
"/api/libraries/{id}/issues/{issueId}/{mediaItemId}/resolve":
post:
tags: [libraries]
+3
View File
@@ -147,6 +147,9 @@ var Schemas = map[string]Schema{
"POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": {
Response: json.RawMessage(`{"success": false, "message": ""}`),
},
"POST /api/libraries/:id/issues/:issueId/retry": {
Response: json.RawMessage(`{"success": false, "message": ""}`),
},
"DELETE /api/libraries/:id/issues/:issueId": {
Response: json.RawMessage(`{"success": false, "message": ""}`),
},
+73
View File
@@ -3,6 +3,8 @@ package handlers
import (
"bookhoard/internal/database"
"context"
"errors"
"fmt"
"net/http"
"time"
@@ -218,6 +220,77 @@ func (h *ProcessingIssuesHandler) gateIssue(c *echo.Context, issueID uuid.UUID)
return nil
}
// RetryProcessingIssue re-runs the work that produced an open issue
// through the individual scanner — NOT a library-wide rescan:
//
// - item-backed issues rescan their media item (the book-detail
// RescanMediaItem mechanism: self-resolving folders, multi-file
// audiobook routing, write-after-success guards);
// - row-less import_failed issues re-import the single file through
// the watcher's standalone processMediaFile path.
//
// Success closes the issue (explicitly for the item shape; the create
// path's self-heal plus the explicit path resolve below for row-less).
// Failure returns the REAL error so the UI can say why it still fails.
func (h *ProcessingIssuesHandler) RetryProcessingIssue(c *echo.Context) error {
issueID, err := uuid.Parse(c.Param("issueId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"})
}
issue, err := h.db.GetProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Issue not found"})
}
if !issue.LibraryID.Valid {
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has no library attribution"})
}
if err := h.gateIssue(c, issueID); err != nil {
return err
}
if issue.Resolved.Valid && issue.Resolved.Bool {
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue is already resolved"})
}
scanner := services.NewMediaScanner(h.db)
defer scanner.Close()
if issue.MediaItemID.Valid {
if err := scanner.RetryMediaItemIssue(c.Request().Context(), issue.MediaItemID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{
"success": true,
"message": "Item rescanned; issue resolved",
})
}
// Row-less import failure: the file never became a row.
if !issue.FilePath.Valid || issue.FilePath.String == "" {
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has neither a media item nor a file path"})
}
relativePath := issue.FilePath.String
if err := scanner.RetryFailedImport(c.Request().Context(), issue.LibraryID, relativePath); err != nil {
if errors.Is(err, services.ErrRetryFileMissing) {
return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// The import chain's self-heal closes path-keyed issues on create;
// an already-imported duplicate (a previous partial retry) skips
// that path, so close explicitly here too. Idempotent.
if _, err := h.db.ResolveOpenProcessingIssuesForPath(c.Request().Context(), database.ResolveOpenProcessingIssuesForPathParams{
LibraryID: issue.LibraryID,
FilePath: pgtype.Text{String: relativePath, Valid: true},
}); err != nil {
fmt.Printf("Warning: retry imported %s but failed to close its issues: %v\n", relativePath, err)
}
return c.JSON(http.StatusOK, map[string]any{
"success": true,
"message": "File imported; issue resolved",
})
}
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
return h.db.GetProcessingIssueStats(ctx, libraryID)
+24
View File
@@ -27,6 +27,11 @@ func (h *Handler) StartBackgroundTasks() {
type ScanLibraryRequest struct {
FolderPaths []string `json:"folder_paths,omitempty"`
LibraryID string `json:"library_id,omitempty"`
// FolderPath (singular) scopes a library scan to ONE of the library's
// registered folders (the LibraryPanel per-folder Scan buttons). It
// must be a registered folder — validated below, never trusted. The
// raw /api/scanner/scan route ignores it (no library attribution).
FolderPath string `json:"folder_path,omitempty"`
Force bool `json:"force,omitempty"`
}
@@ -68,10 +73,29 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"})
}
if req.FolderPath != "" {
// Per-folder scope (LibraryPanel per-folder Scan buttons):
// scan exactly one registered folder's subtree. The archive
// reconciliation pass skips partially-covered libraries
// (processScanJob → ScanFolders), so the other folders'
// items cannot be marked missing by this run.
registered := false
for _, folder := range libraryFolders {
if folder.FolderPath == req.FolderPath {
registered = true
break
}
}
if !registered {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "folder_path is not one of this library's registered folders"})
}
folderPaths = []string{req.FolderPath}
} else {
// Extract folder paths
for _, folder := range libraryFolders {
folderPaths = append(folderPaths, folder.FolderPath)
}
}
if len(folderPaths) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library has no folders configured"})
+61
View File
@@ -1413,6 +1413,67 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String())
}))
// Content partial for the retry queue: after the client drains its
// sequential retry queue it swaps this in over #issues-content so
// resolved issues drop off server-side (unlinked_books matches
// partial precedent). Same family-lens gate as the page.
frontendProtected.GET("/admin/libraries/:id/issues/content", handlers.AdminMiddleware(func(c *echo.Context) error {
libraryID := c.Param("id")
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return renderErrorPage(c, "Invalid library ID", "invalid_id")
}
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
if visErr != nil {
return renderErrorPage(c, "Error checking library access", "visibility_error")
}
if !visibleSet[libraryID] {
return renderErrorPage(c, "Library not accessible", "library_not_accessible")
}
stats, err := cfg.Queries.GetProcessingIssueStats(c.Request().Context(), uuidToPGType(libUUID))
if err != nil {
return renderErrorPage(c, "Error loading stats", "stats_load_error")
}
dbIssues, err := cfg.Queries.ListProcessingIssuesByLibrary(c.Request().Context(), uuidToPGType(libUUID))
if err != nil {
return renderErrorPage(c, "Error loading issues", "issues_load_error")
}
issues := make([]templates.ProcessingIssueData, len(dbIssues))
for i, issue := range dbIssues {
issueUUID, _ := uuid.FromBytes(issue.ID.Bytes[0:16])
itemID := ""
if issue.MediaItemID.Valid {
itemID = uuid.UUID(issue.MediaItemID.Bytes).String()
}
title := issue.Title
if title == "" {
title = issue.FilePath
}
issues[i] = templates.ProcessingIssueData{
ID: issueUUID.String(),
MediaItemID: itemID,
Title: title,
FilePath: issue.FilePath,
FormatGroup: issue.FormatGroup,
LibraryTypeName: issue.LibraryTypeName,
IssueType: issue.IssueType,
IssueDescription: issue.IssueDescription,
Severity: issue.Severity,
CreatedAt: issue.CreatedAt.Time,
}
}
statsData := templates.IssueStats{
ErrorCount: stats.ErrorCount,
WarningCount: stats.WarningCount,
InfoCount: stats.InfoCount,
}
var buf bytes.Buffer
if err := templates.ProcessingIssuesContent(libraryID, issues, statsData).Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
// Admin hash conflicts page: content-duplicate groups flagged during hash
// backfill or rescan, resolved by keeping all copies or merging into one.
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
+1
View File
@@ -39,6 +39,7 @@ func registerLibraryRoutes(cfg *Config) {
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue)
adminLibrary.POST("/:id/issues/:issueId/retry", cfg.ProcessingIssuesHandler.RetryProcessingIssue)
adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
libraryID := c.Param("id")
+54
View File
@@ -844,6 +844,33 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
}
libraryID := lib.LibraryID
// Partial-scope guard (per-folder scans, 2026-10): the
// reconciliation compares EVERY library item against this scan's
// disk walk — a scan that covered only some of the library's
// registered folders (the LibraryPanel per-folder Scan buttons)
// saw nothing from the others and would mark their items missing
// → archived. Reconcile only when the scan covered all of them.
registeredFolders, regErr := s.db.GetLibraryFolders(ctx, libraryID)
if regErr != nil {
fmt.Printf("[ARCHIVE] Warning: failed to list folders for library, skipping archive pass: %v\n", regErr)
continue
}
scannedSet := make(map[string]bool, len(s.folders))
for _, scanned := range s.folders {
scannedSet[scanned] = true
}
covered := 0
for _, rf := range registeredFolders {
if scannedSet[rf.FolderPath] {
covered++
}
}
if covered < len(registeredFolders) {
fmt.Printf("[ARCHIVE] Skipping reconciliation for %s: scan covered %d of %d registered folders\n",
lib.Name, covered, len(registeredFolders))
continue
}
dbItems, err := s.db.ListMediaItemsByLibraryIncludingArchived(ctx, libraryID)
if err != nil {
fmt.Printf("[ARCHIVE] Warning: failed to get library items for archive pass: %v\n", err)
@@ -1577,6 +1604,21 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
}
if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
fmt.Printf("Warning: failed to update media item: %v\n", err)
// Ledger (Phase 11): the item exists — record item-backed so
// the Processing Issues page flags the degraded metadata and
// offers a retry, instead of failing silently on every scan
// (the 2026-10 Crusader genre-22001 loop did exactly that).
// Same type the sidecar-change path uses; resolveIssuesForItem
// clears it when an update finally succeeds.
if logErr := s.LogProcessingIssue(ctx,
uuid.UUID(existingItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
"metadata_extraction_failed",
fmt.Sprintf("Metadata update failed; row left unchanged: %v", err),
"warning"); logErr != nil {
fmt.Printf("Warning: failed to record update issue for %s: %v\n", path, logErr)
}
} else {
s.resolveIssuesForItem(ctx, existingItem.ID)
}
// The bytes changed, so any stored hash is stale.
s.recomputeHashInfo(ctx, entry.ID, libraryID, path)
@@ -1643,6 +1685,18 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
}
if err := s.updateMediaItemWithMetadata(ctx, existingItem, path, metadata); err != nil {
fmt.Printf("Warning: failed to update media item after sidecar change %s: %v\n", path, err)
// Ledger: same item-backed record as the size-change path —
// a failed re-extraction must be visible and retryable, not a
// scan-log whisper.
if logErr := s.LogProcessingIssue(ctx,
uuid.UUID(existingItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
"metadata_extraction_failed",
fmt.Sprintf("Metadata update after sidecar change failed; row left unchanged: %v", err),
"warning"); logErr != nil {
fmt.Printf("Warning: failed to record sidecar-update issue for %s: %v\n", path, logErr)
}
} else {
s.resolveIssuesForItem(ctx, existingItem.ID)
}
// Playlist sync on the change path (plan Correction 2): a
// "(Reading Order)" entry added to an EXISTING book's sidecar
+19 -2
View File
@@ -30,6 +30,8 @@ import (
"strconv"
"strings"
"time"
"bookhoard/internal/utils"
)
// AudioChapter is the uniform chapter entry for every audiobook file model.
@@ -394,7 +396,8 @@ func marshalAudioMetadata(payload *AudioMetadataPayload) []byte {
// ----:com.apple.iTunes:SUBTITLE → joined into the title
// ("Title: Subtitle"), mirroring the ABS sidecar path
// ----:com.apple.iTunes:ASIN / ABRIDGED → asin / abridged flag
// date → publish date; genre → genre
// date → publish date; genre → smart-genre (shelf-or-empty column,
// split candidates to tags — see pickSmartGenre)
//
// Abridged is the one field where an explicit sidecar false must not be
// overridden by a tag — the payload pointer marks sidecar presence.
@@ -472,7 +475,21 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
}
}
if metadata.Genre == "" {
metadata.Genre = report.tag("genre")
// Same doctrine as the ABS sidecar path (ab2a5568): the raw file
// atom is a comma-joined category blob ("Politicians, Presidents &
// Heads of State, United States, ..." — Audible ladders, often past
// 100 chars). Shelf-or-empty wins the genre column; every split
// candidate lands in tags. No shelf match → the column stays empty
// and the blob is tags-only, never a VARCHAR-overflowing INSERT.
if genreTag := report.tag("genre"); genreTag != "" {
genre, all := pickSmartGenre([]string{genreTag})
if genre != "" {
metadata.Genre = genre
}
if len(all) > 0 {
metadata.Tags = utils.NormalizeTags(append(metadata.Tags, all...))
}
}
}
}
@@ -5,6 +5,7 @@ package services
// and tag-based track ordering.
import (
"slices"
"os"
"path/filepath"
"strings"
@@ -178,8 +179,14 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
if meta.PublishDate.Year() != 2020 {
t.Fatalf("publish date = %v", meta.PublishDate)
}
if meta.Genre != "Audiobook" {
t.Fatalf("genre = %q", meta.Genre)
// Smart-genre routing (2026-10 Crusader fix): "Audiobook" is a
// DEMOTED genre (genreDemoted) — it is tags-only and never wins
// the column, mirroring the sidecar path's pickSmartGenre.
if meta.Genre != "" {
t.Fatalf("demoted genre must not win the column, got %q", meta.Genre)
}
if !slices.Contains(meta.Tags, "Audiobook") {
t.Fatalf("demoted genre must stay in tags, got %v", meta.Tags)
}
})
@@ -214,6 +221,46 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
t.Fatal("nil report must change nothing")
}
})
t.Run("audible genre blob splits into tags, column stays empty when no shelf matches", func(t *testing.T) {
// The 2026-10 Crusader regression: Libation writes the Audible
// category ladder as one comma-joined atom — 111 chars here — and
// the fallback used to capture it VERBATIM into media_items.genre,
// failing the import/update with SQLSTATE 22001 (VARCHAR(100)).
// The genre column takes a known shelf or stays empty; the split
// candidates are tags-only, exactly like the sidecar path.
crusader := "Politicians, Presidents & Heads of State, United States, Russia, Communism & Socialism, International Relations"
if len(crusader) <= 100 {
t.Fatalf("fixture must exceed the old VARCHAR(100) limit, len=%d", len(crusader))
}
meta := &MediaMetadata{}
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{"genre": crusader}))
if len(meta.Genre) > 100 {
t.Fatalf("genre column value must never exceed 100 chars, got %d: %q", len(meta.Genre), meta.Genre)
}
if meta.Genre != "" {
t.Fatalf("no candidate is a known shelf — column must stay empty, got %q", meta.Genre)
}
// NormalizeTags titlecases: "of" → "Of" is the shipped display form.
for _, want := range []string{"Politicians", "Presidents & Heads Of State", "United States", "Russia", "Communism & Socialism", "International Relations"} {
if !slices.Contains(meta.Tags, want) {
t.Fatalf("tags must contain split candidate %q, got %v", want, meta.Tags)
}
}
})
t.Run("known shelf wins the column from a genre blob", func(t *testing.T) {
meta := &MediaMetadata{}
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
"genre": "Epic, Action & Adventure, Military, Sword & Sorcery",
}))
if meta.Genre == "" {
t.Fatalf("expected a lexicon shelf from the blob, tags=%v", meta.Tags)
}
if !slices.Contains(meta.Tags, "Epic") || !slices.Contains(meta.Tags, "Military") {
t.Fatalf("split candidates must land in tags, got %v", meta.Tags)
}
})
}
func TestParseTagOrdinal(t *testing.T) {
+92
View File
@@ -0,0 +1,92 @@
package services
// Individual-item retry (2026-10 Processing Issues work): the per-issue
// "Retry" button re-runs the failed work through the individual scanner
// instead of a whole-library rescan. Two shapes mirror the two ledger
// entry kinds:
//
// - item-backed issues → RescanMediaItem (the book-detail rescan
// mechanism; folder resolution, multi-file audiobook routing and the
// write-after-success guards all live there already)
// - row-less import_failed issues → RetryFailedImport below: the
// watcher's standalone per-file path (processMediaFile with no bulk
// scan state), scoped to exactly one file.
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/jackc/pgx/v5/pgtype"
)
// ErrRetryFileMissing reports a retry whose file is no longer on disk —
// the handler maps it to 409 so the UI can say "file gone" rather than
// "retry failed".
var ErrRetryFileMissing = errors.New("media file not found on disk")
// RetryFailedImport re-imports a single file whose media_items row was
// never created (a row-less import_failed ledger entry). It resolves the
// library's folders itself (the RescanMediaItem precedent) so relative
// paths stay library-root-relative, then runs the ordinary single-file
// import chain — sidecars → folder structure → tags → filename — with
// the create path's own ledger self-heal (resolveIssuesForPath) closing
// the issue on success.
func (s *MediaScanner) RetryFailedImport(ctx context.Context, libraryID pgtype.UUID, relativePath string) error {
if !libraryID.Valid || relativePath == "" {
return fmt.Errorf("library id and file path are required")
}
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
if err != nil || len(folders) == 0 {
return fmt.Errorf("no library folders found for library")
}
folderPaths := make([]string, 0, len(folders))
for _, folder := range folders {
folderPaths = append(folderPaths, folder.FolderPath)
}
s.folders = folderPaths
// processMediaFile prefers the default library over per-folder
// resolution; setting it keeps a path that straddles folder roots
// attributed to the ledger entry's library.
s.SetLibraryID(libraryID)
var fullPath string
for _, folder := range folders {
candidate := filepath.Join(folder.FolderPath, relativePath)
if _, statErr := os.Stat(candidate); statErr == nil {
fullPath = candidate
break
}
}
if fullPath == "" {
return fmt.Errorf("%w: %s", ErrRetryFileMissing, relativePath)
}
imported, err := s.processMediaFile(ctx, fullPath)
if err != nil {
return err
}
if !imported {
return fmt.Errorf("file was skipped (not a scannable media file, or identical content already registered)")
}
return nil
}
// RetryMediaItemIssue re-extracts one item-backed issue's media item
// through the book-detail rescan mechanism and closes the issue when the
// rescan succeeds. Kept next to RetryFailedImport so the retry handler
// reads as the one piece of new surface it is.
func (s *MediaScanner) RetryMediaItemIssue(ctx context.Context, mediaItemID pgtype.UUID) error {
if err := s.RescanMediaItem(ctx, mediaItemID, false); err != nil {
return err
}
// RescanMediaItem doesn't self-heal the ledger; the retry that was
// explicitly asked for does.
if _, err := s.db.ResolveOpenProcessingIssuesForItem(ctx, mediaItemID); err != nil {
fmt.Printf("Warning: retry resolved item %s but failed to close its issues: %v\n", mediaItemID, err)
}
return nil
}
+12
View File
@@ -267,6 +267,18 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
for _, folder := range folders {
<div class="flex items-center justify-between gap-2 p-2 rounded-lg" style="background-color: var(--bg-primary);">
<code class="text-xs flex-1 truncate" style="color: var(--text-primary)">{ folder.FolderPath }</code>
<button
class="icon-btn h-7 w-7 shrink-0"
hx-post={ "/api/libraries/" + libraryID + "/scan" }
hx-vals={ hxValsFolderScan(folder.FolderPath) }
hx-target="#scan-indicator"
hx-swap="innerHTML"
hx-confirm="Scan this folder?"
title="Scan this folder"
aria-label="Scan this folder"
>
@Icon("refresh", "h-3.5 w-3.5")
</button>
<button
class="icon-btn h-7 w-7 shrink-0"
hx-delete={ "/admin/library/" + libraryID + "/folders" }
+254 -220
View File
@@ -506,14 +506,14 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</code> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-delete=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</code> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/scan")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 272, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 272, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
@@ -524,28 +524,62 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderScan(folder.FolderPath))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 273, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 273, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-target=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-target=\"#scan-indicator\" hx-swap=\"innerHTML\" hx-confirm=\"Scan this folder?\" title=\"Scan this folder\" aria-label=\"Scan this folder\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</button> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 274, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" hx-swap=\"innerHTML\" hx-confirm=\"Remove this folder from the library?\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 285, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 286, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" hx-swap=\"innerHTML\" hx-confirm=\"Remove this folder from the library?\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -553,82 +587,82 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<form class=\"flex gap-2\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 124}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" hx-swap=\"innerHTML\"><input type=\"text\" name=\"folder_path\" id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<form class=\"flex gap-2\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 288, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 296, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" placeholder=\"/path/to/books\" class=\"input flex-1\" required> <button type=\"button\" data-library-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 295, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 296, Col: 124}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" data-target-input=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" hx-swap=\"innerHTML\"><input type=\"text\" name=\"folder_path\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 296, Col: 52}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 300, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" @click=\"openFolderBrowser($el.dataset.targetInput, $el.dataset.libraryId)\" class=\"btn btn-secondary text-sm shrink-0\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" placeholder=\"/path/to/books\" class=\"input flex-1\" required> <button type=\"button\" data-library-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 307, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" data-target-input=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 308, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" @click=\"openFolderBrowser($el.dataset.targetInput, $el.dataset.libraryId)\" class=\"btn btn-secondary text-sm shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -636,7 +670,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Browse</button> <button type=\"submit\" class=\"btn btn-primary text-sm shrink-0\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "Browse</button> <button type=\"submit\" class=\"btn btn-primary text-sm shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -644,12 +678,12 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "Add</button></form></div><!-- User Visibility -->")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "Add</button></form></div><!-- User Visibility -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(users) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<div><div class=\"flex items-center gap-2 mb-3\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<div><div class=\"flex items-center gap-2 mb-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -657,115 +691,115 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<h4 class=\"text-sm font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">User Access</h4></div><div class=\"space-y-1\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<h4 class=\"text-sm font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">User Access</h4></div><div class=\"space-y-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, u := range users {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<label class=\"flex items-center gap-3 cursor-pointer p-2 rounded-lg transition-colors hover:bg-surface-hover\"><input type=\"checkbox\" class=\"w-4 h-4 rounded\" name=\"is_visible\" value=\"true\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<label class=\"flex items-center gap-3 cursor-pointer p-2 rounded-lg transition-colors hover:bg-surface-hover\"><input type=\"checkbox\" class=\"w-4 h-4 rounded\" name=\"is_visible\" value=\"true\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if isUserVisible(u.ID, visibility) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " checked")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 325, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 326, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" hx-trigger=\"change\" hx-target=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 328, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 337, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" hx-swap=\"innerHTML\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 331, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 338, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" hx-trigger=\"change\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 332, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 340, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span></label>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" hx-swap=\"innerHTML\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</div></div>")
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<!-- Processing Issues -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if issueCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<div><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 templ.SafeURL
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 341, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 343, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 344, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span></label>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<!-- Processing Issues -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if issueCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 templ.SafeURL
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 353, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -774,25 +808,25 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
return templ_7745c5c3_Err
}
if issueCount == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span>1 processing issue</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span>1 processing issue</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 346, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 358, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " processing issues</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " processing issues</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -801,51 +835,51 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</a></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<!-- Actions --><div class=\"flex gap-2 pt-2 border-t\" style=\"border-color: var(--border);\"><button data-edit-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 355, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" data-edit-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 356, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" data-edit-desc=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<!-- Actions --><div class=\"flex gap-2 pt-2 border-t\" style=\"border-color: var(--border);\"><button data-edit-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 357, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 367, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" @click=\"openEditModal($el.dataset.editId, $el.dataset.editName, $el.dataset.editDesc)\" class=\"btn btn-secondary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" data-edit-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 368, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" data-edit-desc=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 369, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" @click=\"openEditModal($el.dataset.editId, $el.dataset.editName, $el.dataset.editDesc)\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -853,33 +887,33 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "Edit Details</button> <button data-delete-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Edit Details</button> <button data-delete-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 365, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 377, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" data-delete-name=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" data-delete-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 366, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 378, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" @click=\"openDeleteModal($el.dataset.deleteId, $el.dataset.deleteName)\" class=\"btn btn-danger text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" @click=\"openDeleteModal($el.dataset.deleteId, $el.dataset.deleteName)\" class=\"btn btn-danger text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -887,7 +921,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Delete Library</button></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "Delete Library</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -911,12 +945,12 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var38 := templ.GetChildren(ctx)
if templ_7745c5c3_Var38 == nil {
templ_7745c5c3_Var38 = templ.NopComponent
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
if templ_7745c5c3_Var40 == nil {
templ_7745c5c3_Var40 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<div><div class=\"flex items-center gap-2 mb-3 p-2 rounded-lg\" style=\"background-color: var(--bg-primary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<div><div class=\"flex items-center gap-2 mb-3 p-2 rounded-lg\" style=\"background-color: var(--bg-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -924,38 +958,38 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<code class=\"text-xs flex-1 truncate\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<code class=\"text-xs flex-1 truncate\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 381, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 393, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</code></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</code></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if parentPath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 387, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 399, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -963,30 +997,30 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<span>..</span></button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<span>..</span></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<div class=\"space-y-1 max-h-64 overflow-y-auto\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<div class=\"space-y-1 max-h-64 overflow-y-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, entry := range entries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\" hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 400, Col: 118}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 412, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -994,51 +1028,51 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 405, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</span></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</div><div class=\"mt-4 flex justify-end\"><button type=\"button\" data-target-input=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 412, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" data-current-path=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<span class=\"truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 413, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 417, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" @click=\"pickBrowsePath($event)\" class=\"btn btn-primary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</span></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</div><div class=\"mt-4 flex justify-end\"><button type=\"button\" data-target-input=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 424, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" data-current-path=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 425, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\" @click=\"pickBrowsePath($event)\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1046,7 +1080,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "Select This Folder</button></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "Select This Folder</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+48 -7
View File
@@ -12,7 +12,13 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
</head>
<body class={ "theme-" + user.Theme }>
@Header(user, "/admin/library")
<main class="p-8">
<!-- The retry queue component: per-issue Retry buttons and Retry all
run each issue through the individual scanner sequentially (one
book in flight at a time). The whole variable section below is a
templ partial (#issues-content) so the client can re-render
server truth after the queue drains — htmx.ajax swap, never
JSON-into-innerHTML. -->
<main class="p-8" x-data="processingIssues" data-library-id={ libraryID }>
<div class="mx-auto max-w-4xl">
<div class="mb-8">
<div>
@@ -25,9 +31,24 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
<p class="text-sm" style="color: var(--text-secondary)">Items that couldn't be processed in this library</p>
</div>
</div>
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
@ProcessingIssuesContent(libraryID, issues, stats)
</div>
</main>
</body>
</html>
}
// ProcessingIssuesContent is the swappable body of the Processing Issues
// page: the severity stat cards (with the Retry-all control on the right
// of the errors row) and the issue card list. Rendered by the page and by
// the /admin/libraries/:id/issues/content partial the retry queue swaps
// in after draining.
templ ProcessingIssuesContent(libraryID string, issues []ProcessingIssueData, stats IssueStats) {
<div id="issues-content">
if len(issues) > 0 {
<div class="flex items-start justify-between gap-6 mb-8">
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 flex-1">
if stats.ErrorCount > 0 {
<div class="stat-card" style="border-left: 4px solid var(--status-danger);">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-danger);">
@@ -56,6 +77,21 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
</div>
}
</div>
<!-- Retry all: right of the errors row. Queues every issue
currently on the page through the individual scanner,
one at a time — deliberately NOT a whole-library rescan. -->
<button
@click="retryAll()"
:disabled="queueRunning"
class="btn btn-primary shrink-0"
>
<span x-show="!queueRunning" class="inline-flex items-center gap-2">
@Icon("refresh", "h-4 w-4")
<span>Retry all</span>
</span>
<span x-show="queueRunning" x-text="queueRunning ? (queueDone + ' / ' + queueTotal) : ''"></span>
</button>
</div>
}
if len(issues) == 0 {
<div class="card p-8 text-center">
@@ -68,7 +104,7 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
<!-- Issues List -->
<div class="space-y-4">
for _, issue := range issues {
<div class="card p-6" id={ "issue-" + issue.ID }>
<div class="card p-6" id={ "issue-" + issue.ID } data-issue-id={ issue.ID }>
<div class="flex justify-between items-start gap-4 mb-4">
<div class="flex-1 min-w-0">
<h4 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ issue.Title }</h4>
@@ -91,6 +127,14 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
</div>
</div>
<div class="flex gap-3 mt-4">
<button
class="btn btn-primary text-sm issue-retry-btn"
@click={ "retryOne(" + JSONArg(issue.ID) + ")" }
:disabled="queueRunning"
>
@Icon("refresh", "h-4 w-4")
Retry
</button>
if issue.Severity == "warning" || issue.Severity == "info" {
<button
hx-post={ "/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve" }
@@ -109,7 +153,4 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
</div>
}
</div>
</main>
</body>
</html>
}
+233 -136
View File
@@ -59,7 +59,20 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<main class=\"p-8\"><div class=\"mx-auto max-w-4xl\"><div class=\"mb-8\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!-- The retry queue component: per-issue Retry buttons and Retry all\n\t\t run each issue through the individual scanner sequentially (one\n\t\t book in flight at a time). The whole variable section below is a\n\t\t templ partial (#issues-content) so the client can re-render\n\t\t server truth after the queue drains — htmx.ajax swap, never\n\t\t JSON-into-innerHTML. --><main class=\"p-8\" x-data=\"processingIssues\" data-library-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 21, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><div class=\"mx-auto max-w-4xl\"><div class=\"mb-8\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -67,17 +80,59 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Processing Issues</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Items that couldn't be processed in this library</p></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Processing Issues</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Items that couldn't be processed in this library</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
templ_7745c5c3_Err = ProcessingIssuesContent(libraryID, issues, stats).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div></main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// ProcessingIssuesContent is the swappable body of the Processing Issues
// page: the severity stat cards (with the Retry-all control on the right
// of the errors row) and the issue card list. Rendered by the page and by
// the /admin/libraries/:id/issues/content partial the retry queue swaps
// in after draining.
func ProcessingIssuesContent(libraryID string, issues []ProcessingIssueData, stats IssueStats) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var5 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div id=\"issues-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(issues) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"flex items-start justify-between gap-6 mb-8\"><!-- Stats Cards --><div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 flex-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if stats.ErrorCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-danger);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-danger);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-danger);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-danger);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -85,43 +140,16 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Errors</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Errors</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 37, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 58, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if stats.WarningCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-warning);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Warnings</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 46, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -130,25 +158,25 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
return templ_7745c5c3_Err
}
}
if stats.InfoCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-info);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-info);\">")
if stats.WarningCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-warning);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("info", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Info</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Warnings</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 55, Col: 91}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 67, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -157,13 +185,48 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
if stats.InfoCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-info);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-info);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("info", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Info</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><!-- Retry all: right of the errors row. Queues every issue\n\t\t\t\t currently on the page through the individual scanner,\n\t\t\t\t one at a time — deliberately NOT a whole-library rescan. --><button @click=\"retryAll()\" :disabled=\"queueRunning\" class=\"btn btn-primary shrink-0\"><span x-show=\"!queueRunning\" class=\"inline-flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span>Retry all</span></span> <span x-show=\"queueRunning\" x-text=\"queueRunning ? (queueDone + ' / ' + queueTotal) : ''\"></span></button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(issues) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"card p-8 text-center\"><span class=\"grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"card p-8 text-center\"><span class=\"grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -171,198 +234,232 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No processing issues found for this library.</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No processing issues found for this library.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<!-- Issues List --> <div class=\"space-y-4\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<!-- Issues List --> <div class=\"space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, issue := range issues {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"card p-6\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 71, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\"><div class=\"flex justify-between items-start gap-4 mb-4\"><div class=\"flex-1 min-w-0\"><h4 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"card p-6\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 75, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 107, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p><div class=\"text-sm space-y-1\" style=\"color: var(--text-secondary)\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Type:</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-issue-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 140}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 107, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Format:</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"><div class=\"flex justify-between items-start gap-4 mb-4\"><div class=\"flex-1 min-w-0\"><h4 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 144}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 110, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">File:</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 139}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 111, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Library:</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p><div class=\"text-sm space-y-1\" style=\"color: var(--text-secondary)\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Type:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 80, Col: 149}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 113, Col: 137}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></div></div><div class=\"ml-2 shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if issue.Severity == "error" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<span class=\"badge status-failed\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Format:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 85, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 114, Col: 141}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if issue.Severity == "warning" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"badge status-pending\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">File:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 87, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 115, Col: 136}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"badge status-processing\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Library:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 89, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 116, Col: 146}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</p></div></div><div class=\"ml-2 shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></div><div class=\"flex gap-3 mt-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if issue.Severity == "warning" || issue.Severity == "info" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<button hx-post=\"")
if issue.Severity == "error" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"badge status-failed\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 113}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 121, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" hx-target=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if issue.Severity == "warning" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span class=\"badge status-pending\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 97, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 123, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" hx-swap=\"outerHTML\" hx-confirm=\"Dismiss this issue?\" class=\"btn btn-secondary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span class=\"badge status-processing\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 125, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></div><div class=\"flex gap-3 mt-4\"><button class=\"btn btn-primary text-sm issue-retry-btn\" @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("retryOne(" + JSONArg(issue.ID) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 132, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" :disabled=\"queueRunning\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "Retry</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if issue.Severity == "warning" || issue.Severity == "info" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<button hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 140, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 141, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" hx-swap=\"outerHTML\" hx-confirm=\"Dismiss this issue?\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -370,22 +467,22 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Dismiss</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "Dismiss</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div></main></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+1 -1
View File
@@ -218,7 +218,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</head>
<body x-data="collections" x-init="initCollectionsPage()" class={ "theme-" + user.Theme }>
@Header(user, "/collections")
@LibrarySwitcher(libData, libraryID, "")
@LibrarySwitcher(libData, libraryID, "", nil)
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<a href="/collections" class="btn btn-secondary mb-4">
+1 -1
View File
@@ -613,7 +613,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = LibrarySwitcher(libData, libraryID, "").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = LibrarySwitcher(libData, libraryID, "", nil).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+44 -29
View File
@@ -18,7 +18,7 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
</head>
<body x-data="dashboard" x-init="initDashboard()" class={ "theme-" + user.Theme }>
@Header(user, "/dashboard")
@LibrarySwitcher(libData, currentLibraryID, "Your Library", DashboardActions())
@LibrarySwitcher(libData, currentLibraryID, "Your Library", weekWidgetComponent(week, activeLibType), DashboardActions())
<!-- OUTSIDE #collections-container on purpose: the switcher's
re-render wipes that container's innerHTML, and anything
server-rendered inside it dies on the first library
@@ -41,11 +41,6 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
</a>
</div>
}
if week != nil {
<div class="w-full px-4 sm:px-6 lg:px-8 pt-6">
@DashboardWeekWidget(*week, activeLibType)
</div>
}
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 pt-5 pb-8">
for _, section := range sections {
@CollectionCarousel(section)
@@ -317,41 +312,56 @@ type BookStatsData struct {
FirstSession string
}
// DashboardWeekWidget renders the collapsible "This Week" strip. The
// collapsed summary row reads at a glance on every screen size; the
// expanded body holds the full grid. The details element re-fetches
// itself through htmx when the body sees a `week-refresh` event
// (dispatched by the library switcher), carrying the CURRENT
// #library-select value, so the widget tracks the switched library.
// weekWidgetComponent adapts the optional week stats into the
// LibrarySwitcher's center slot. nil (stats unavailable or the stats
// service is off) = the slot stays empty, exactly as when the widget
// used to be suppressed entirely.
func weekWidgetComponent(week *WeekStatsData, activeLibType string) templ.Component {
if week == nil {
return nil
}
return DashboardWeekWidget(*week, activeLibType)
}
// DashboardWeekWidget renders the "This Week" stats that live in the
// library bar's center slot (LibrarySwitcher's `center` component).
// The summary is a compact inline row whose numbers reveal themselves
// as width allows; the expanded body is an absolutely positioned
// popover that FLOATS over the page content — opening it never shifts
// the collections below. The details element re-fetches itself through
// htmx when the body sees a `week-refresh` event (dispatched by the
// library switcher), carrying the CURRENT #library-select value, so
// the widget tracks the switched library. Visible at every width — on
// narrow screens the summary collapses to the chart icon + chevron
// (tap target, aria-labeled; "This Week" + minutes reveal at `sm`,
// pages/sessions/books at md/lg/xl). The panel anchors to the
// BAR (full-width under it) below `sm` — the widget is too far left on
// phones for widget-centered anchoring — and centers on the widget at
// `sm` and up.
templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
<details
id="week-widget"
class="card group"
class="group sm:relative"
hx-get="/dashboard/week-widget"
hx-trigger="week-refresh from:body"
hx-include="#library-select"
hx-swap="outerHTML"
>
<summary class="px-4 md:px-6 py-3 flex flex-wrap items-center gap-x-5 gap-y-1 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
<span class="flex items-center gap-2 font-bold tracking-tight" style="color: var(--text-primary)">
<span class="grid place-items-center h-6 w-6 rounded-md" style="background-color: var(--accent-muted); color: var(--accent);">
<summary class="flex items-center gap-2 cursor-pointer select-none list-none rounded-lg px-2 py-1 hover:bg-surface-hover [&::-webkit-details-marker]:hidden" aria-label="Reading stats for this week">
<span class="grid place-items-center h-6 w-6 rounded-md shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("chart", "h-4 w-4")
</span>
This Week
</span>
<span class="text-sm font-semibold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d pages", week.WeekPages) }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d sessions", week.WeekSessions) }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d books", week.WeekBooks) }</span>
<span class="text-sm font-bold tracking-tight whitespace-nowrap hidden sm:inline" style="color: var(--text-primary)">This Week</span>
<span class="text-sm font-semibold whitespace-nowrap hidden sm:inline" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</span>
<span class="text-xs whitespace-nowrap hidden md:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d pages", week.WeekPages) }</span>
<span class="text-xs whitespace-nowrap hidden lg:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d sessions", week.WeekSessions) }</span>
<span class="text-xs whitespace-nowrap hidden xl:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d books", week.WeekBooks) }</span>
if week.CurrentStreak > 0 {
<span class="text-sm font-medium" style="color: var(--accent)">🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak</span>
<span class="text-xs font-medium whitespace-nowrap hidden lg:inline" style="color: var(--accent)">🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak</span>
}
<span class="ml-auto flex items-center gap-3">
<a href="/stats" class="text-xs font-medium hover:underline" style="color: var(--accent);" @click.stop>Full stats</a>
@Icon("chevron-down", "h-4 w-4 transition-transform group-open:rotate-180")
</span>
@Icon("chevron-down", "h-4 w-4 shrink-0 transition-transform group-open:rotate-180")
</summary>
<div class="px-4 md:px-6 pb-4 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
<div class="card absolute top-full left-4 right-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 mt-2 sm:w-[30rem] max-w-[calc(100vw-2rem)] z-40 p-4 md:px-6 md:py-5" style="box-shadow: var(--shadow-pop);">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
if activeLibType == "audiobooks" {
<div>
@@ -387,7 +397,8 @@ templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
</div>
}
</div>
<p class="text-xs mt-3" style="color: var(--text-secondary)">
<div class="flex items-end justify-between gap-4 mt-3 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
<p class="text-xs" style="color: var(--text-secondary)">
Synced from your reading devices
if week.CurrentStreak > 0 {
if week.ActiveToday {
@@ -397,6 +408,10 @@ templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
}
}
</p>
<a href="/stats" class="inline-flex items-center gap-1 text-xs font-medium hover:underline shrink-0 whitespace-nowrap" style="color: var(--accent);">Full stats
@Icon("arrow-right", "h-4 w-4")
</a>
</div>
</div>
</details>
}
File diff suppressed because it is too large. Load diff
+12 -2
View File
@@ -1,6 +1,13 @@
package templates
templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, actions ...templ.Component) {
// LibrarySwitcher renders the sticky subbar: title + library select on
// the left (title hidden below `sm` — phones need the row width for the
// select + actions + the dashboard's stats widget), optional action
// buttons on the right. `center` is an
// optional slot between them (nil = unused) — the dashboard passes its
// "This Week" stats widget here so it participates in the bar's
// justify-between layout as the middle group.
templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, center templ.Component, actions ...templ.Component) {
<div
class="app-subbar backdrop-blur border-b"
style="background-color: color-mix(in srgb, var(--bg-primary) 88%, transparent); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);"
@@ -8,7 +15,7 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
<div class="w-full px-4 sm:px-6 lg:px-8 py-3 flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
if title != "" {
<span class="text-base font-bold tracking-tight shrink-0" style="color: var(--text-primary)">{ title }</span>
<span class="text-base font-bold tracking-tight shrink-0 hidden sm:inline" style="color: var(--text-primary)">{ title }</span>
} else {
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library</label>
}
@@ -33,6 +40,9 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
}
</select>
</div>
if center != nil {
@center
}
if len(actions) > 0 {
<div class="flex items-center gap-2">
for _, action := range actions {
+29 -16
View File
@@ -8,7 +8,14 @@ package templates
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, actions ...templ.Component) templ.Component {
// LibrarySwitcher renders the sticky subbar: title + library select on
// the left (title hidden below `sm` — phones need the row width for the
// select + actions + the dashboard's stats widget), optional action
// buttons on the right. `center` is an
// optional slot between them (nil = unused) — the dashboard passes its
// "This Week" stats widget here so it participates in the bar's
// justify-between layout as the middle group.
func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, center templ.Component, actions ...templ.Component) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -34,14 +41,14 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
return templ_7745c5c3_Err
}
if title != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"text-base font-bold tracking-tight shrink-0\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"text-base font-bold tracking-tight shrink-0 hidden sm:inline\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 11, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 18, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -64,7 +71,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 20, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 27, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -84,7 +91,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 24, Col: 29}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -97,7 +104,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 24, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -110,7 +117,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 24, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -128,7 +135,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 26, Col: 29}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
@@ -141,7 +148,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 26, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -154,7 +161,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 26, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -173,7 +180,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 29, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -186,7 +193,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 29, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -199,7 +206,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 29, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -217,7 +224,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -230,7 +237,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -243,7 +250,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -259,6 +266,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if center != nil {
templ_7745c5c3_Err = center.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(actions) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
+1 -1
View File
@@ -17,7 +17,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
</head>
<body x-data="seriesPage" x-init="initSeriesPage()" class={ "theme-" + user.Theme }>
@Header(user, "/series")
@LibrarySwitcher(libData, currentLibraryID, "")
@LibrarySwitcher(libData, currentLibraryID, "", nil)
<div id="series-container">
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6 flex items-center gap-3">
+1 -1
View File
@@ -64,7 +64,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = LibrarySwitcher(libData, currentLibraryID, "").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = LibrarySwitcher(libData, currentLibraryID, "", nil).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+12
View File
@@ -383,6 +383,18 @@ func hxValsFolderDelete(folderPath string) string {
return "js:" + string(b)
}
// hxValsFolderScan builds the hx-vals for the LibraryPanel per-folder
// Scan button: it scopes the library scan endpoint to ONE registered
// folder (ScanLibraryRequest.FolderPath). Plain JSON, no js: prefix —
// the eval-free form under the hash CSP.
func hxValsFolderScan(folderPath string) string {
b, err := json.Marshal(map[string]string{"folder_path": folderPath})
if err != nil {
return "{}"
}
return string(b)
}
// fmtSecondsHMS renders seconds as H:MM:SS (or M:SS under an hour) —
// the audio progress display shape shared with the web player.
func fmtSecondsHMS(seconds float64) string {
+17
View File
@@ -508,6 +508,23 @@ function initDashboard() {
}
});
// The week-widget popover floats over the page content (absolute
// panel inside the library bar), so a click anywhere outside the open
// <details> closes it — same for Escape. The element is re-queried on
// every event because htmx replaces #week-widget on library switches.
document.addEventListener("click", (e: Event) => {
const widget = document.getElementById("week-widget") as HTMLDetailsElement | null;
if (widget?.open && !widget.contains(e.target as Node)) {
widget.open = false;
}
});
document.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
const widget = document.getElementById("week-widget") as HTMLDetailsElement | null;
if (widget?.open) widget.open = false;
});
if (!scanListenerRegistered) {
scanListenerRegistered = true;
window.addEventListener("bookhoard:scan-complete", async () => {
+1
View File
@@ -22,6 +22,7 @@ import "./library-switcher";
import "./linking";
import "./login";
import "./password_validation";
import "./processing_issues";
import "./profile";
import "./profile-modal";
import "./register";
+117
View File
@@ -0,0 +1,117 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
import { getToken } from "./storage";
// Processing Issues retry queue (2026-10). The "Retry all" button is a
// CLIENT-side queue over the single-issue retry endpoint: one book in
// flight at a time through the individual scanner (RescanMediaItem /
// single-file import), never a whole-library rescan — that resource
// trade is the whole point. Issue cards are templ-rendered; the queue
// counter and per-card tint below are pure client-local state (the
// sanctioned island shape; after the queue drains the templ partial
// swap restores server truth).
interface RetryResult {
ok: boolean;
message?: string;
error?: string;
}
async function retryIssue(libraryId: string, issueId: string): Promise<RetryResult> {
const token = getToken();
let resp: Response;
try {
resp = await fetch(`/api/libraries/${libraryId}/issues/${issueId}/retry`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
} catch (err) {
return { ok: false, error: (err as Error).message };
}
const body = (await resp.json().catch(() => ({}))) as {
success?: boolean;
message?: string;
error?: string;
};
return { ok: resp.ok, message: body.message, error: body.error };
}
Alpine.data("processingIssues", () => ({
queueRunning: false,
queueDone: 0,
queueTotal: 0,
libraryId: "",
init(): void {
this.libraryId = (this.$el as HTMLElement).dataset.libraryId || "";
},
issueIdsOnPage(): string[] {
return Array.from(document.querySelectorAll<HTMLElement>("#issues-content [data-issue-id]"))
.map((el) => el.dataset.issueId || "")
.filter((id) => id !== "");
},
async retryOne(issueId: string): Promise<void> {
await this.runQueue([issueId]);
},
async retryAll(): Promise<void> {
await this.runQueue(this.issueIdsOnPage());
},
async runQueue(issueIds: string[]): Promise<void> {
if (this.queueRunning || issueIds.length === 0 || !this.libraryId) return;
this.queueRunning = true;
this.queueDone = 0;
this.queueTotal = issueIds.length;
let failed = 0;
for (const issueId of issueIds) {
const card = document.querySelector(
`#issues-content [data-issue-id="${CSS.escape(issueId)}"]`,
);
card?.classList.add("issue-retrying");
const result = await retryIssue(this.libraryId, issueId);
card?.classList.remove("issue-retrying");
if (result.ok) {
card?.classList.add("issue-retry-done");
} else {
failed++;
card?.classList.add("issue-retry-failed");
showToast(result.error || `Retry failed for ${issueId.slice(0, 8)}`, "error");
}
this.queueDone++;
}
this.queueRunning = false;
if (failed === 0) {
showToast(
`Retried ${issueIds.length} item${issueIds.length === 1 ? "" : "s"} successfully`,
"success",
);
} else {
showToast(
`Retried ${issueIds.length - failed} of ${issueIds.length}; ${failed} still failing`,
"warning",
);
}
this.refreshList();
},
// Server truth after the queue drains: resolved issues drop off the
// ledger. Swap the templ partial (rendering ownership: the list is
// server data), falling back to a reload when htmx is unavailable.
refreshList(): void {
if (window.htmx) {
window.htmx
.ajax("GET", `/admin/libraries/${this.libraryId}/issues/content`, {
target: "#issues-content",
swap: "outerHTML",
})
.catch(() => window.location.reload());
} else {
window.location.reload();
}
},
}));
+14
View File
@@ -2113,3 +2113,17 @@
.nav-group-items[data-collapsed="true"] {
display: none;
}
/* Processing Issues retry queue (2026-10): client-side per-card tints.
Cards themselves are templ-rendered; these only mark queue progress —
the post-queue partial swap restores server truth. */
.issue-retrying {
opacity: 0.55;
}
.issue-retry-done {
border-color: var(--status-success) !important;
}
.issue-retry-failed {
border-color: var(--status-danger) !important;
}
+1 -1
View File
@@ -1,2 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated