Compare commits

...
11 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
John O'Keefe f6a73ab5aa feat(unlinked-books): dismiss (ignore) or delete queue entries
Release / build-and-push (push) Successful in 3m15s
Unidentified sync files used to be stuck: every KOReader re-sync
re-queues them, and there was no way to say "this isn't a book" or "I
don't want this one on the server".

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

Actions are page-scoped SSR routes: htmx swaps in the re-rendered
section so counts and buckets can never drift from the DB; ownership
is row → device → user (admins may act on any row). Integration tests
pin the lifecycle (hide everywhere, survive the sync upsert, restore,
delete), foreign-user 403s, and the render surface.
2026-10-26 14:58:50 -04:00
47 changed files with 3022 additions and 1063 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 Project-wide context lives in the sibling repos' AGENTS.md files
(../bookhoard-app/AGENTS.md is the Android client's). (../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 ## Rendering ownership (templ vs TypeScript) — ALWAYS apply
The point of templ + htmx + Alpine is: the server renders the UI, and 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: info:
name: Scan Library name: Scan Library
type: http type: http
seq: 18 seq: 19
http: http:
method: POST method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info: info:
name: Set Library Visibility name: Set Library Visibility
type: http type: http
seq: 19 seq: 20
http: http:
method: POST method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info: info:
name: Update Library name: Update Library
type: http type: http
seq: 20 seq: 21
http: http:
method: PUT method: PUT
+1 -1
View File
@@ -1,7 +1,7 @@
info: info:
name: Update Scan Settings name: Update Scan Settings
type: http type: http
seq: 21 seq: 22
http: http:
method: PUT 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)
}
+189
View File
@@ -0,0 +1,189 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// unlinkedBooksFixture is one user + device + two queue rows. The regular
// user's cleanup cascades: users → devices → unlinked_books.
type unlinkedBooksFixture struct {
setup *TestServerSetup
owner UserTestData
ownerTok string
deviceID pgtype.UUID
contentA string
contentB string
rowA pgtype.UUID
rowB pgtype.UUID
}
func setupUnlinkedBooksFixture(t *testing.T) *unlinkedBooksFixture {
f := &unlinkedBooksFixture{setup: setupTestServer(t), contentA: "test-content-aaa", contentB: "test-content-bbb"}
f.owner = createRegularUserOnce(t, f.setup.DB)
f.ownerTok = loginUserWithCredentials(t, f.setup.Server, f.owner.Email, f.owner.Password)
device, err := f.setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
UserID: pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true},
DeviceName: "test-unlinked-reader",
DeviceType: "koreader",
DeviceIdentifier: "test-unlinked-" + uuid.New().String()[:8],
AuthToken: "dev_test_" + uuid.New().String(),
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "device for unlinked-books tests")
f.deviceID = device.ID
f.rowA = f.seedRow(t, f.contentA, "Test Book A")
f.rowB = f.seedRow(t, f.contentB, "Test Book B")
return f
}
// seedRow inserts one queue row for the fixture device.
func (f *unlinkedBooksFixture) seedRow(t *testing.T, contentID, title string) pgtype.UUID {
row, err := f.setup.DB.CreateUnlinkedBook(context.Background(), database.CreateUnlinkedBookParams{
DeviceID: f.deviceID,
ContentID: contentID,
Title: pgtype.Text{String: title, Valid: true},
})
require.NoError(t, err, "seed unlinked_books row")
return row.ID
}
// doAction fires an htmx-style page action as the given user.
func (f *unlinkedBooksFixture) doAction(t *testing.T, token, method, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Cookie", "token="+token)
req.Header.Set("HX-Request", "true")
rec := httptest.NewRecorder()
f.setup.Server.Config.Handler.ServeHTTP(rec, req)
return rec
}
func (f *unlinkedBooksFixture) unresolvedCount(t *testing.T) int {
rows, err := f.setup.DB.ListUnresolvedUnlinkedBooksForUser(context.Background(),
pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
require.NoError(t, err)
return len(rows)
}
func (f *unlinkedBooksFixture) ignoredIDs(t *testing.T) []uuid.UUID {
rows, err := f.setup.DB.ListIgnoredUnlinkedBooksForUser(context.Background(),
pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
require.NoError(t, err)
ids := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
ids = append(ids, uuid.UUID(r.ID.Bytes))
}
return ids
}
// TestUnlinkedBooksIgnoreLifecycle pins the soft-dismiss contract: hidden
// from the page list, badge count, and per-device API; preserved across
// the sync upsert; reversible via unignore. Hard delete is covered too.
func TestUnlinkedBooksIgnoreLifecycle(t *testing.T) {
f := setupUnlinkedBooksFixture(t)
ctx := context.Background()
pathA := "/unlinked-books/" + uuid.UUID(f.rowA.Bytes).String()
pathB := "/unlinked-books/" + uuid.UUID(f.rowB.Bytes).String()
t.Run("ignore hides the row everywhere", func(t *testing.T) {
rec := f.doAction(t, f.ownerTok, "POST", pathA+"/ignore")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
// The htmx swap re-renders the whole section partial.
assert.Contains(t, rec.Body.String(), `id="unlinked-section"`)
assert.Equal(t, 1, f.unresolvedCount(t), "row B remains queued")
ignored := f.ignoredIDs(t)
require.Len(t, ignored, 1)
assert.Equal(t, uuid.UUID(f.rowA.Bytes), ignored[0])
count, err := f.setup.DB.CountUnresolvedUnlinkedBooksForUser(ctx, pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
require.NoError(t, err)
assert.Equal(t, int64(1), count, "dashboard badge must still count row B only")
perDevice, err := f.setup.DB.GetUnlinkedBooksByDevice(ctx, f.deviceID)
require.NoError(t, err)
assert.Len(t, perDevice, 1, "per-device API must hide the ignored row")
deviceCount, err := f.setup.DB.CountUnlinkedBooks(ctx, f.deviceID)
require.NoError(t, err)
assert.Equal(t, int64(1), deviceCount)
})
t.Run("device sync refresh preserves the ignore", func(t *testing.T) {
// The exact query the KOReader sync path runs on every re-sync.
_, err := f.setup.DB.UpsertUnlinkedBook(ctx, database.UpsertUnlinkedBookParams{
DeviceID: f.deviceID,
ContentID: f.contentA,
Title: pgtype.Text{String: "Test Book A (restored metadata)", Valid: true},
})
require.NoError(t, err)
row, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowA)
require.NoError(t, err)
assert.True(t, row.IgnoredAt.Valid, "upsert must never clear ignored_at")
assert.Equal(t, "Test Book A (restored metadata)", row.Title.String, "upsert still refreshes metadata")
assert.Equal(t, 1, f.unresolvedCount(t), "ignored row must not re-enter the queue")
})
t.Run("unignore restores the row to the queue", func(t *testing.T) {
rec := f.doAction(t, f.ownerTok, "POST", pathA+"/unignore")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Equal(t, 2, f.unresolvedCount(t))
assert.Empty(t, f.ignoredIDs(t))
row, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowA)
require.NoError(t, err)
assert.False(t, row.IgnoredAt.Valid)
})
t.Run("delete removes the row outright", func(t *testing.T) {
rec := f.doAction(t, f.ownerTok, "DELETE", pathB)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
_, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowB)
assert.Error(t, err, "hard-deleted row must be gone")
assert.Equal(t, 1, f.unresolvedCount(t))
})
t.Run("unknown and malformed ids are rejected", func(t *testing.T) {
rec := f.doAction(t, f.ownerTok, "DELETE", "/unlinked-books/"+uuid.New().String())
assert.Equal(t, http.StatusNotFound, rec.Code)
rec = f.doAction(t, f.ownerTok, "POST", "/unlinked-books/not-a-uuid/ignore")
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
}
// TestUnlinkedBooksOwnership pins row → device → user authorization:
// another user's queue rows are untouchable, ignored or deleted.
func TestUnlinkedBooksOwnership(t *testing.T) {
f := setupUnlinkedBooksFixture(t)
stranger := createRegularUserOnce(t, f.setup.DB)
strangerTok := loginUserWithCredentials(t, f.setup.Server, stranger.Email, stranger.Password)
pathA := "/unlinked-books/" + uuid.UUID(f.rowA.Bytes).String()
rec := f.doAction(t, strangerTok, "POST", pathA+"/ignore")
assert.Equal(t, http.StatusForbidden, rec.Code)
row, err := f.setup.DB.GetUnlinkedBookByID(context.Background(), f.rowA)
require.NoError(t, err)
assert.False(t, row.IgnoredAt.Valid, "foreign ignore must not land")
rec = f.doAction(t, strangerTok, "DELETE", pathA)
assert.Equal(t, http.StatusForbidden, rec.Code)
_, err = f.setup.DB.GetUnlinkedBookByID(context.Background(), f.rowA)
assert.NoError(t, err, "foreign delete must not land")
}
+15 -1
View File
@@ -174,7 +174,7 @@ CREATE TABLE IF NOT EXISTS media_items (
language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1) language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1)
edition VARCHAR(100), -- Edition information edition VARCHAR(100), -- Edition information
page_count INTEGER, -- Total page count 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 copyright_year INTEGER, -- Copyright/publication year
goodreads_id VARCHAR(50), -- Goodreads identifier goodreads_id VARCHAR(50), -- Goodreads identifier
openlibrary_id VARCHAR(50), -- Open Library 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 tags_search TEXT[];
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[]; ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
-- 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 -- Audiobook editorial facts (A2a): narrators are user-editable first-class
-- (search/filter/badge surface); abridged rides along; audio_metadata keeps -- (search/filter/badge surface); abridged rides along; audio_metadata keeps
-- the scan-derived tech record (duration/codec/chapters). -- the scan-derived tech record (duration/codec/chapters).
@@ -1496,6 +1504,12 @@ CREATE TABLE IF NOT EXISTS unlinked_books (
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
-- Soft-dismiss for unlinked_books: ignored rows stay on the device's sync
-- queue (UpsertUnlinkedBook keeps refreshing their file metadata, and must
-- never clear this column) but are hidden from the page list, badge count,
-- and per-device API. Reversible via UnignoreUnlinkedBook.
ALTER TABLE unlinked_books ADD COLUMN IF NOT EXISTS ignored_at TIMESTAMP WITH TIME ZONE;
CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id); CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id); CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
-- One queue row per (device, document): the sync ingest upserts on this pair. -- One queue row per (device, document): the sync ingest upserts on this pair.
@@ -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 # 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` from the path — no request body is needed (the generic `POST /api/scanner/scan`
variant is the one that takes `folder_paths`). 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 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. 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 | | 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 | | 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 | | 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 | | 500 | `{"error": "failed to enqueue scan job: ..."}` | queue failure |
+2 -1
View File
@@ -35,7 +35,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
"username": "john", "username": "john",
"first_name": "John", "first_name": "John",
"last_name": "Doe", "last_name": "Doe",
"role": "user" "role": "user",
"keep_screen_awake": true
} }
``` ```
+10 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Bookhoard API title: Bookhoard API
version: '2026.10.24' version: '2026.10.27'
description: | description: |
Generated from the canonical API docs (docs/developer/api/**) by Generated from the canonical API docs (docs/developer/api/**) by
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update). internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
@@ -1141,6 +1141,15 @@ paths:
responses: responses:
'200': '200':
description: Success 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": "/api/libraries/{id}/issues/{issueId}/{mediaItemId}/resolve":
post: post:
tags: [libraries] tags: [libraries]
+1
View File
@@ -562,6 +562,7 @@ type UnlinkedBooks struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"` ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
} }
type UserDashboardPreferences struct { type UserDashboardPreferences struct {
+16 -4
View File
@@ -53,9 +53,10 @@ type Querier interface {
// collections page); same live filter as GetCollectionItems, plus the // collections page); same live filter as GetCollectionItems, plus the
// future-hybrid excluded exclusion and an optional library scope. // future-hybrid excluded exclusion and an optional library scope.
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error) CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
// Count unlinked books for a device // Count unlinked books for a device (ignored rows stay hidden)
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
// Banner count: unresolved queue rows across the caller's own devices. // Banner count: unresolved, non-ignored queue rows across the caller's own
// devices. Ignored rows must not nag from the dashboard badge.
CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error) CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
@@ -456,7 +457,9 @@ type Querier interface {
GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error)
// Get unlinked book by ID // Get unlinked book by ID
GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error) GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error)
// Get unlinked books for a device // Fetch one row together with its device's owner for authorization.
GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error)
// Get unlinked books for a device (ignored rows stay hidden)
GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
@@ -504,6 +507,9 @@ type Querier interface {
// static collection paths apply. // static collection paths apply.
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error) HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
// Soft-dismiss one row (ownership is checked by the caller against the
// device's user_id before calling this).
IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error) IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
// Check if book is in collection // Check if book is in collection
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error) IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
@@ -530,6 +536,9 @@ type Querier interface {
// archived row's reading data into its active twin instead of waiting for a // archived row's reading data into its active twin instead of waiting for a
// file that will never return. // file that will never return.
ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error)
// Page list, ignored bucket: rows the user dismissed, kept for the
// collapsible "Ignored" section so a misclick is recoverable.
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
@@ -553,7 +562,8 @@ type Querier interface {
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
// List unresolved unlinked books with pagination // List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
// Page list: unresolved queue rows for the caller's own devices. // Page list: unresolved queue rows for the caller's own devices (ignored
// rows are listed by ListIgnoredUnlinkedBooksForUser instead).
ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk. // First consecutive scan that cannot find the file on disk.
@@ -637,6 +647,8 @@ type Querier interface {
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
// Undo a soft-dismiss.
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
// Update collection // Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write // Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
+171 -17
View File
@@ -372,10 +372,10 @@ func (q *Queries) CountCollectionLiveItems(ctx context.Context, arg CountCollect
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM unlinked_books FROM unlinked_books
WHERE device_id = $1 AND resolved = false WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL
` `
// Count unlinked books for a device // Count unlinked books for a device (ignored rows stay hidden)
func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) { func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUnlinkedBooks, deviceID) row := q.db.QueryRow(ctx, CountUnlinkedBooks, deviceID)
var count int64 var count int64
@@ -387,10 +387,11 @@ const CountUnresolvedUnlinkedBooksForUser = `-- name: CountUnresolvedUnlinkedBoo
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM unlinked_books ub FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
` `
// Banner count: unresolved queue rows across the caller's own devices. // Banner count: unresolved, non-ignored queue rows across the caller's own
// devices. Ignored rows must not nag from the dashboard badge.
func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) { func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID) row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID)
var count int64 var count int64
@@ -1956,7 +1957,7 @@ const CreateUnlinkedBook = `-- name: CreateUnlinkedBook :one
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score) INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
` `
type CreateUnlinkedBookParams struct { type CreateUnlinkedBookParams struct {
@@ -1996,6 +1997,7 @@ func (q *Queries) CreateUnlinkedBook(ctx context.Context, arg CreateUnlinkedBook
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
) )
return i, err return i, err
} }
@@ -9809,7 +9811,7 @@ func (q *Queries) GetUniversalProgress(ctx context.Context, arg GetUniversalProg
} }
const GetUnlinkedBookByContentId = `-- name: GetUnlinkedBookByContentId :one const GetUnlinkedBookByContentId = `-- name: GetUnlinkedBookByContentId :one
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2 SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2
` `
type GetUnlinkedBookByContentIdParams struct { type GetUnlinkedBookByContentIdParams struct {
@@ -9835,12 +9837,13 @@ func (q *Queries) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinke
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
) )
return i, err return i, err
} }
const GetUnlinkedBookByID = `-- name: GetUnlinkedBookByID :one const GetUnlinkedBookByID = `-- name: GetUnlinkedBookByID :one
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE id = $1 SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE id = $1
` `
// Get unlinked book by ID // Get unlinked book by ID
@@ -9861,15 +9864,69 @@ func (q *Queries) GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (Unli
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
const GetUnlinkedBookWithOwner = `-- name: GetUnlinkedBookWithOwner :one
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.user_id, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.id = $1
`
type GetUnlinkedBookWithOwnerRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Fetch one row together with its device's owner for authorization.
func (q *Queries) GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error) {
row := q.db.QueryRow(ctx, GetUnlinkedBookWithOwner, id)
var i GetUnlinkedBookWithOwnerRow
err := row.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.UserID,
&i.DeviceName,
&i.DeviceType,
) )
return i, err return i, err
} }
const GetUnlinkedBooksByDevice = `-- name: GetUnlinkedBooksByDevice :many const GetUnlinkedBooksByDevice = `-- name: GetUnlinkedBooksByDevice :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id JOIN devices d ON ub.device_id = d.id
WHERE ub.device_id = $1 AND ub.resolved = false WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC ORDER BY ub.last_seen_at DESC
` `
@@ -9887,11 +9944,12 @@ type GetUnlinkedBooksByDeviceRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"` ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"` DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"` DeviceType string `db:"device_type" json:"device_type"`
} }
// Get unlinked books for a device // Get unlinked books for a device (ignored rows stay hidden)
func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error) { func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error) {
rows, err := q.db.Query(ctx, GetUnlinkedBooksByDevice, deviceID) rows, err := q.db.Query(ctx, GetUnlinkedBooksByDevice, deviceID)
if err != nil { if err != nil {
@@ -9915,6 +9973,7 @@ func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName, &i.DeviceName,
&i.DeviceType, &i.DeviceType,
); err != nil { ); err != nil {
@@ -10985,6 +11044,17 @@ func (q *Queries) HasRecentConflictResolution(ctx context.Context, arg HasRecent
return exists, err return exists, err
} }
const IgnoreUnlinkedBook = `-- name: IgnoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1
`
// Soft-dismiss one row (ownership is checked by the caller against the
// device's user_id before calling this).
func (q *Queries) IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, IgnoreUnlinkedBook, id)
return err
}
const IncrementSyncQueueAttempts = `-- name: IncrementSyncQueueAttempts :one const IncrementSyncQueueAttempts = `-- name: IncrementSyncQueueAttempts :one
UPDATE sync_queue UPDATE sync_queue
SET SET
@@ -11071,7 +11141,7 @@ SET
resolved_at = NOW(), resolved_at = NOW(),
resolution_method = 'manual_link' resolution_method = 'manual_link'
WHERE id = $1 WHERE id = $1
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
` `
type LinkUnlinkedBookParams struct { type LinkUnlinkedBookParams struct {
@@ -11098,6 +11168,7 @@ func (q *Queries) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookPara
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
) )
return i, err return i, err
} }
@@ -11680,6 +11751,72 @@ func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaIt
return items, nil return items, nil
} }
const ListIgnoredUnlinkedBooksForUser = `-- name: ListIgnoredUnlinkedBooksForUser :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
ORDER BY ub.ignored_at DESC
`
type ListIgnoredUnlinkedBooksForUserRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Page list, ignored bucket: rows the user dismissed, kept for the
// collapsible "Ignored" section so a misclick is recoverable.
func (q *Queries) ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error) {
rows, err := q.db.Query(ctx, ListIgnoredUnlinkedBooksForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListIgnoredUnlinkedBooksForUserRow{}
for rows.Next() {
var i ListIgnoredUnlinkedBooksForUserRow
if err := rows.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLibraries = `-- name: ListLibraries :many const ListLibraries = `-- name: ListLibraries :many
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description
FROM libraries l FROM libraries l
@@ -13040,7 +13177,7 @@ func (q *Queries) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUI
} }
const ListUnresolvedUnlinkedBooks = `-- name: ListUnresolvedUnlinkedBooks :many const ListUnresolvedUnlinkedBooks = `-- name: ListUnresolvedUnlinkedBooks :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id JOIN devices d ON ub.device_id = d.id
WHERE ub.resolved = false WHERE ub.resolved = false
@@ -13067,6 +13204,7 @@ type ListUnresolvedUnlinkedBooksRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"` ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"` DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"` DeviceType string `db:"device_type" json:"device_type"`
} }
@@ -13095,6 +13233,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName, &i.DeviceName,
&i.DeviceType, &i.DeviceType,
); err != nil { ); err != nil {
@@ -13109,10 +13248,10 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
} }
const ListUnresolvedUnlinkedBooksForUser = `-- name: ListUnresolvedUnlinkedBooksForUser :many const ListUnresolvedUnlinkedBooksForUser = `-- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC ORDER BY ub.last_seen_at DESC
` `
@@ -13130,11 +13269,13 @@ type ListUnresolvedUnlinkedBooksForUserRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"` ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"` DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"` DeviceType string `db:"device_type" json:"device_type"`
} }
// Page list: unresolved queue rows for the caller's own devices. // Page list: unresolved queue rows for the caller's own devices (ignored
// rows are listed by ListIgnoredUnlinkedBooksForUser instead).
func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) { func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) {
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID) rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID)
if err != nil { if err != nil {
@@ -13158,6 +13299,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName, &i.DeviceName,
&i.DeviceType, &i.DeviceType,
); err != nil { ); err != nil {
@@ -13814,7 +13956,7 @@ SET
resolved_at = NOW(), resolved_at = NOW(),
resolution_method = $3 resolution_method = $3
WHERE id = $1 WHERE id = $1
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
` `
type ResolveUnlinkedBookParams struct { type ResolveUnlinkedBookParams struct {
@@ -13841,6 +13983,7 @@ func (q *Queries) ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBo
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
) )
return i, err return i, err
} }
@@ -15246,6 +15389,16 @@ func (q *Queries) TombstoneSystemCollection(ctx context.Context, id pgtype.UUID)
return err return err
} }
const UnignoreUnlinkedBook = `-- name: UnignoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1
`
// Undo a soft-dismiss.
func (q *Queries) UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, UnignoreUnlinkedBook, id)
return err
}
const UpdateCollection = `-- name: UpdateCollection :one const UpdateCollection = `-- name: UpdateCollection :one
UPDATE collections UPDATE collections
SET SET
@@ -17649,7 +17802,7 @@ SET file_path = EXCLUDED.file_path,
author = EXCLUDED.author, author = EXCLUDED.author,
confidence_score = EXCLUDED.confidence_score, confidence_score = EXCLUDED.confidence_score,
last_seen_at = NOW() last_seen_at = NOW()
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
` `
type UpsertUnlinkedBookParams struct { type UpsertUnlinkedBookParams struct {
@@ -17687,6 +17840,7 @@ func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBook
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
) )
return i, err return i, err
} }
+35 -8
View File
@@ -3701,12 +3701,12 @@ INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, con
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *; RETURNING *;
-- Get unlinked books for a device -- Get unlinked books for a device (ignored rows stay hidden)
-- name: GetUnlinkedBooksByDevice :many -- name: GetUnlinkedBooksByDevice :many
SELECT ub.*, d.device_name, d.device_type SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id JOIN devices d ON ub.device_id = d.id
WHERE ub.device_id = $1 AND ub.resolved = false WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC; ORDER BY ub.last_seen_at DESC;
-- Get unlinked book by ContentId -- Get unlinked book by ContentId
@@ -3724,11 +3724,11 @@ SET
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
-- Count unlinked books for a device -- Count unlinked books for a device (ignored rows stay hidden)
-- name: CountUnlinkedBooks :one -- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM unlinked_books FROM unlinked_books
WHERE device_id = $1 AND resolved = false; WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL;
-- Link unlinked book to media item -- Link unlinked book to media item
-- name: LinkUnlinkedBook :one -- name: LinkUnlinkedBook :one
@@ -3772,21 +3772,48 @@ SET file_path = EXCLUDED.file_path,
last_seen_at = NOW() last_seen_at = NOW()
RETURNING *; RETURNING *;
-- Banner count: unresolved queue rows across the caller's own devices. -- Banner count: unresolved, non-ignored queue rows across the caller's own
-- devices. Ignored rows must not nag from the dashboard badge.
-- name: CountUnresolvedUnlinkedBooksForUser :one -- name: CountUnresolvedUnlinkedBooksForUser :one
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM unlinked_books ub FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false; WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL;
-- Page list: unresolved queue rows for the caller's own devices. -- Page list: unresolved queue rows for the caller's own devices (ignored
-- rows are listed by ListIgnoredUnlinkedBooksForUser instead).
-- name: ListUnresolvedUnlinkedBooksForUser :many -- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.*, d.device_name, d.device_type SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC; ORDER BY ub.last_seen_at DESC;
-- Page list, ignored bucket: rows the user dismissed, kept for the
-- collapsible "Ignored" section so a misclick is recoverable.
-- name: ListIgnoredUnlinkedBooksForUser :many
SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
ORDER BY ub.ignored_at DESC;
-- Soft-dismiss one row (ownership is checked by the caller against the
-- device's user_id before calling this).
-- name: IgnoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1;
-- Undo a soft-dismiss.
-- name: UnignoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1;
-- Fetch one row together with its device's owner for authorization.
-- name: GetUnlinkedBookWithOwner :one
SELECT ub.*, d.user_id, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.id = $1;
-- ============================================ -- ============================================
-- CAROUSEL-STYLE DASHBOARD -- CAROUSEL-STYLE DASHBOARD
-- ============================================ -- ============================================
+3
View File
@@ -147,6 +147,9 @@ var Schemas = map[string]Schema{
"POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": { "POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": {
Response: json.RawMessage(`{"success": false, "message": ""}`), 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": { "DELETE /api/libraries/:id/issues/:issueId": {
Response: json.RawMessage(`{"success": false, "message": ""}`), Response: json.RawMessage(`{"success": false, "message": ""}`),
}, },
+73
View File
@@ -3,6 +3,8 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"context" "context"
"errors"
"fmt"
"net/http" "net/http"
"time" "time"
@@ -218,6 +220,77 @@ func (h *ProcessingIssuesHandler) gateIssue(c *echo.Context, issueID uuid.UUID)
return nil 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) // GetProcessingIssueStatsData returns stats for SSR (not JSON response)
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) { func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
return h.db.GetProcessingIssueStats(ctx, libraryID) return h.db.GetProcessingIssueStats(ctx, libraryID)
+28 -4
View File
@@ -27,7 +27,12 @@ func (h *Handler) StartBackgroundTasks() {
type ScanLibraryRequest struct { type ScanLibraryRequest struct {
FolderPaths []string `json:"folder_paths,omitempty"` FolderPaths []string `json:"folder_paths,omitempty"`
LibraryID string `json:"library_id,omitempty"` LibraryID string `json:"library_id,omitempty"`
Force bool `json:"force,omitempty"` // FolderPath (singular) scopes a library scan to ONE of the library's
// registered folders (the LibraryPanel per-folder Scan buttons). It
// must be a registered folder — validated below, never trusted. The
// raw /api/scanner/scan route ignores it (no library attribution).
FolderPath string `json:"folder_path,omitempty"`
Force bool `json:"force,omitempty"`
} }
// ScanLibrary handles POST /api/scanner/scan (now runs in background) // ScanLibrary handles POST /api/scanner/scan (now runs in background)
@@ -68,9 +73,28 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"}) return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"})
} }
// Extract folder paths if req.FolderPath != "" {
for _, folder := range libraryFolders { // Per-folder scope (LibraryPanel per-folder Scan buttons):
folderPaths = append(folderPaths, folder.FolderPath) // 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 { if len(folderPaths) == 0 {
+188 -1
View File
@@ -767,6 +767,8 @@ func registerFrontendRoutes(cfg *Config) {
// resolution rules as the dashboard page (family-lens safe). // resolution rules as the dashboard page (family-lens safe).
// Unlinked-books page: the identification queue for device files the // Unlinked-books page: the identification queue for device files the
// sync could not match (personal data — the caller's own devices only). // sync could not match (personal data — the caller's own devices only).
// Unlinked-book queue page: unresolved rows plus the ignored bucket
// (soft-dismissed rows, kept for the collapsible recovery section).
frontendProtected.GET("/unlinked-books", func(c *echo.Context) error { frontendProtected.GET("/unlinked-books", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg) user, err := getTemplateUserWithTheme(c, cfg)
if err != nil { if err != nil {
@@ -781,6 +783,11 @@ func registerFrontendRoutes(cfg *Config) {
log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr) log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr)
rows = []database.ListUnresolvedUnlinkedBooksForUserRow{} rows = []database.ListUnresolvedUnlinkedBooksForUserRow{}
} }
ignoredRows, ignoredErr := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if ignoredErr != nil {
log.Printf("ListIgnoredUnlinkedBooksForUser failed: %v", ignoredErr)
ignoredRows = []database.ListIgnoredUnlinkedBooksForUserRow{}
}
books := make([]templates.UnlinkedBookData, 0, len(rows)) books := make([]templates.UnlinkedBookData, 0, len(rows))
for _, r := range rows { for _, r := range rows {
@@ -796,14 +803,133 @@ func registerFrontendRoutes(cfg *Config) {
ConfidenceScore: r.ConfidenceScore.Float64, ConfidenceScore: r.ConfidenceScore.Float64,
}) })
} }
ignoredBooks := make([]templates.UnlinkedBookData, 0, len(ignoredRows))
for _, r := range ignoredRows {
ignoredBooks = append(ignoredBooks, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, user.Timezone),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
var buf bytes.Buffer var buf bytes.Buffer
if renderErr := templates.UnlinkedBooks(user, books).Render(c.Request().Context(), &buf); renderErr != nil { if renderErr := templates.UnlinkedBooks(user, books, ignoredBooks).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr return renderErr
} }
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
}) })
// Unlinked-book dismiss/delete actions (htmx: the buttons post/delete
// and swap in the re-rendered section). Authorization is row → device
// → requesting user (admin may act on anyone's rows); a non-htmx hit
// just bounces back to the page. These are page-scoped SSR routes, so
// they are intentionally NOT under /api and not in the OpenAPI docs.
unlinkedSectionHTML := func(c *echo.Context, dbUser database.Users) error {
userUUID, err := uuid.Parse(dbUser.ID.String())
if err != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
rows, err := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
}
ignoredRows, err := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
}
books := make([]templates.UnlinkedBookData, 0, len(rows))
for _, r := range rows {
books = append(books, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
ignoredBooks := make([]templates.UnlinkedBookData, 0, len(ignoredRows))
for _, r := range ignoredRows {
ignoredBooks = append(ignoredBooks, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedBooksSection(books, ignoredBooks).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
}
// authorizeUnlinkedAction resolves the :id row with its device owner
// and gates the action: nil row + status/message on failure.
authorizeUnlinkedAction := func(c *echo.Context, dbUser database.Users) (*database.GetUnlinkedBookWithOwnerRow, int, string) {
rowID, err := uuid.Parse(c.Param("id"))
if err != nil {
return nil, http.StatusBadRequest, "<div>Invalid entry id</div>"
}
row, err := cfg.Queries.GetUnlinkedBookWithOwner(c.Request().Context(), pgtype.UUID{Bytes: rowID, Valid: true})
if err != nil {
return nil, http.StatusNotFound, "<div>Entry not found</div>"
}
ownerID := uuid.UUID(row.UserID.Bytes).String()
if ownerID != dbUser.ID.String() && dbUser.Role != "admin" {
return nil, http.StatusForbidden, "<div>Not allowed</div>"
}
return &row, 0, ""
}
for _, action := range []struct {
method string
path string
run func(ctx context.Context, rowID pgtype.UUID) error
}{
{"POST", "/unlinked-books/:id/ignore", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.IgnoreUnlinkedBook(ctx, rowID)
}},
{"POST", "/unlinked-books/:id/unignore", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.UnignoreUnlinkedBook(ctx, rowID)
}},
{"DELETE", "/unlinked-books/:id", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.DeleteUnlinkedBook(ctx, rowID)
}},
} {
method, path, run := action.method, action.path, action.run
frontendProtected.Add(method, path, func(c *echo.Context) error {
dbUser, ok := c.Get("user").(database.Users)
if !ok {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
row, status, msg := authorizeUnlinkedAction(c, dbUser)
if row == nil {
return c.HTML(status, msg)
}
if err := run(c.Request().Context(), row.ID); err != nil {
log.Printf("unlinked-book action %s %s failed: %v", method, path, err)
return c.HTML(http.StatusInternalServerError, "<div>Action failed</div>")
}
if c.Request().Header.Get("HX-Request") != "true" {
return c.Redirect(http.StatusSeeOther, "/unlinked-books")
}
return unlinkedSectionHTML(c, dbUser)
})
}
// Unlinked-book match results partial: the match cascade for one device // Unlinked-book match results partial: the match cascade for one device
// file, rendered server-side and scoped to the requester's visible // file, rendered server-side and scoped to the requester's visible
// libraries (absent-from-results) — rendering-ownership: templ renders, // libraries (absent-from-results) — rendering-ownership: templ renders,
@@ -1287,6 +1413,67 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String()) 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 // Admin hash conflicts page: content-duplicate groups flagged during hash
// backfill or rescan, resolved by keeping all copies or merging into one. // backfill or rescan, resolved by keeping all copies or merging into one.
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error { 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/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats) adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue) 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.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
adminLibrary.POST("/:id/scan", func(c *echo.Context) error { adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
libraryID := c.Param("id") libraryID := c.Param("id")
+54
View File
@@ -844,6 +844,33 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
} }
libraryID := lib.LibraryID 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) dbItems, err := s.db.ListMediaItemsByLibraryIncludingArchived(ctx, libraryID)
if err != nil { if err != nil {
fmt.Printf("[ARCHIVE] Warning: failed to get library items for archive pass: %v\n", err) 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 { if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
fmt.Printf("Warning: failed to update media item: %v\n", err) 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. // The bytes changed, so any stored hash is stale.
s.recomputeHashInfo(ctx, entry.ID, libraryID, path) 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 { 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) 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 // Playlist sync on the change path (plan Correction 2): a
// "(Reading Order)" entry added to an EXISTING book's sidecar // "(Reading Order)" entry added to an EXISTING book's sidecar
+19 -2
View File
@@ -30,6 +30,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"bookhoard/internal/utils"
) )
// AudioChapter is the uniform chapter entry for every audiobook file model. // 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 // ----:com.apple.iTunes:SUBTITLE → joined into the title
// ("Title: Subtitle"), mirroring the ABS sidecar path // ("Title: Subtitle"), mirroring the ABS sidecar path
// ----:com.apple.iTunes:ASIN / ABRIDGED → asin / abridged flag // ----: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 // Abridged is the one field where an explicit sidecar false must not be
// overridden by a tag — the payload pointer marks sidecar presence. // overridden by a tag — the payload pointer marks sidecar presence.
@@ -472,7 +475,21 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
} }
} }
if metadata.Genre == "" { 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. // and tag-based track ordering.
import ( import (
"slices"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -178,8 +179,14 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
if meta.PublishDate.Year() != 2020 { if meta.PublishDate.Year() != 2020 {
t.Fatalf("publish date = %v", meta.PublishDate) t.Fatalf("publish date = %v", meta.PublishDate)
} }
if meta.Genre != "Audiobook" { // Smart-genre routing (2026-10 Crusader fix): "Audiobook" is a
t.Fatalf("genre = %q", meta.Genre) // 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.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) { 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 { for _, folder := range folders {
<div class="flex items-center justify-between gap-2 p-2 rounded-lg" style="background-color: var(--bg-primary);"> <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> <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 <button
class="icon-btn h-7 w-7 shrink-0" class="icon-btn h-7 w-7 shrink-0"
hx-delete={ "/admin/library/" + libraryID + "/folders" } hx-delete={ "/admin/library/" + libraryID + "/folders" }
+224 -190
View File
@@ -506,14 +506,14 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders") templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/scan")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -524,28 +524,62 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath)) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderScan(folder.FolderPath))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 string 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 { 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) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -553,82 +587,82 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</button></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<form class=\"flex gap-2\" hx-post=\"") 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_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=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var23 string 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 { 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) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var24 string 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 { 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) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -636,7 +670,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -644,12 +678,12 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(users) > 0 { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -657,115 +691,115 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, u := range users { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if isUserVisible(u.ID, visibility) { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " hx-post=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " 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=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var28 string 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 { 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) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var29 string 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 { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var30 string 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 { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span></label>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" hx-swap=\"innerHTML\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 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, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<!-- Processing Issues -->") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<!-- Processing Issues -->")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if issueCount > 0 { if issueCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<div><a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div><a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var31 templ.SafeURL var templ_7745c5c3_Var33 templ.SafeURL
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues") templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 341, Col: 57} 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_Var31)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -774,25 +808,25 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if issueCount == 1 { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var32 string var templ_7745c5c3_Var34 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount) templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -801,51 +835,51 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</a></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</a></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<!-- Actions --><div class=\"flex gap-2 pt-2 border-t\" style=\"border-color: var(--border);\"><button data-edit-id=\"") 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_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=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var35 string 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 { 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) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -853,33 +887,33 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var36 string var templ_7745c5c3_Var38 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var37 string var templ_7745c5c3_Var39 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name) templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -887,7 +921,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -911,12 +945,12 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var38 := templ.GetChildren(ctx) templ_7745c5c3_Var40 := templ.GetChildren(ctx)
if templ_7745c5c3_Var38 == nil { if templ_7745c5c3_Var40 == nil {
templ_7745c5c3_Var38 = templ.NopComponent templ_7745c5c3_Var40 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -924,38 +958,38 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var39 string var templ_7745c5c3_Var41 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath) templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if parentPath != "" { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var40 string var templ_7745c5c3_Var42 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID) templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -963,30 +997,30 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<span>..</span></button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<span>..</span></button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, entry := range entries { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var41 string var templ_7745c5c3_Var43 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID) templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -994,51 +1028,51 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"truncate\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<span class=\"truncate\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var42 string var templ_7745c5c3_Var44 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name) templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 405, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 417, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</span></button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</span></button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</div><div class=\"mt-4 flex justify-end\"><button type=\"button\" data-target-input=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</div><div class=\"mt-4 flex justify-end\"><button type=\"button\" data-target-input=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var43 string var templ_7745c5c3_Var45 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput) templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 412, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 424, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" data-current-path=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" data-current-path=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var44 string var templ_7745c5c3_Var46 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath) templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 413, Col: 35} 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_Var44) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" @click=\"pickBrowsePath($event)\" class=\"btn btn-primary text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\" @click=\"pickBrowsePath($event)\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1046,7 +1080,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+123 -82
View File
@@ -12,7 +12,13 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
</head> </head>
<body class={ "theme-" + user.Theme }> <body class={ "theme-" + user.Theme }>
@Header(user, "/admin/library") @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="mx-auto max-w-4xl">
<div class="mb-8"> <div class="mb-8">
<div> <div>
@@ -25,91 +31,126 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
<p class="text-sm" style="color: var(--text-secondary)">Items that couldn't be processed in this library</p> <p class="text-sm" style="color: var(--text-secondary)">Items that couldn't be processed in this library</p>
</div> </div>
</div> </div>
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 { @ProcessingIssuesContent(libraryID, issues, stats)
<!-- Stats Cards --> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> </main>
if stats.ErrorCount > 0 { </body>
<div class="stat-card" style="border-left: 4px solid var(--status-danger);"> </html>
<div class="flex items-center gap-2 mb-2" style="color: var(--status-danger);"> }
@Icon("x-circle", "h-5 w-5")
<h3 class="text-sm font-semibold uppercase tracking-wide">Errors</h3> // ProcessingIssuesContent is the swappable body of the Processing Issues
</div> // page: the severity stat cards (with the Retry-all control on the right
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.ErrorCount }</p> // of the errors row) and the issue card list. Rendered by the page and by
</div> // the /admin/libraries/:id/issues/content partial the retry queue swaps
} // in after draining.
if stats.WarningCount > 0 { templ ProcessingIssuesContent(libraryID string, issues []ProcessingIssueData, stats IssueStats) {
<div class="stat-card" style="border-left: 4px solid var(--status-warning);"> <div id="issues-content">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-warning);"> if len(issues) > 0 {
@Icon("alert", "h-5 w-5") <div class="flex items-start justify-between gap-6 mb-8">
<h3 class="text-sm font-semibold uppercase tracking-wide">Warnings</h3> <!-- Stats Cards -->
</div> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 flex-1">
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.WarningCount }</p> if stats.ErrorCount > 0 {
</div> <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 stats.InfoCount > 0 { @Icon("x-circle", "h-5 w-5")
<div class="stat-card" style="border-left: 4px solid var(--status-info);"> <h3 class="text-sm font-semibold uppercase tracking-wide">Errors</h3>
<div class="flex items-center gap-2 mb-2" style="color: var(--status-info);"> </div>
@Icon("info", "h-5 w-5") <p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.ErrorCount }</p>
<h3 class="text-sm font-semibold uppercase tracking-wide">Info</h3>
</div>
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.InfoCount }</p>
</div>
}
</div> </div>
} }
if len(issues) == 0 { if stats.WarningCount > 0 {
<div class="card p-8 text-center"> <div class="stat-card" style="border-left: 4px solid var(--status-warning);">
<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);"> <div class="flex items-center gap-2 mb-2" style="color: var(--status-warning);">
@Icon("check-circle", "h-6 w-6") @Icon("alert", "h-5 w-5")
</span> <h3 class="text-sm font-semibold uppercase tracking-wide">Warnings</h3>
<p class="text-sm" style="color: var(--text-secondary)">No processing issues found for this library.</p> </div>
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.WarningCount }</p>
</div> </div>
} else { }
<!-- Issues List --> if stats.InfoCount > 0 {
<div class="space-y-4"> <div class="stat-card" style="border-left: 4px solid var(--status-info);">
for _, issue := range issues { <div class="flex items-center gap-2 mb-2" style="color: var(--status-info);">
<div class="card p-6" id={ "issue-" + issue.ID }> @Icon("info", "h-5 w-5")
<div class="flex justify-between items-start gap-4 mb-4"> <h3 class="text-sm font-semibold uppercase tracking-wide">Info</h3>
<div class="flex-1 min-w-0"> </div>
<h4 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ issue.Title }</h4> <p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.InfoCount }</p>
<p class="mb-3 text-sm" style="color: var(--text-secondary)">{ issue.IssueDescription }</p>
<div class="text-sm space-y-1" style="color: var(--text-secondary)">
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Type:</span> { issue.IssueType }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Format:</span> { issue.FormatGroup }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">File:</span> { issue.FilePath }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Library:</span> { issue.LibraryTypeName }</p>
</div>
</div>
<div class="ml-2 shrink-0">
if issue.Severity == "error" {
<span class="badge status-failed">{ issue.Severity }</span>
} else if issue.Severity == "warning" {
<span class="badge status-pending">{ issue.Severity }</span>
} else {
<span class="badge status-processing">{ issue.Severity }</span>
}
</div>
</div>
<div class="flex gap-3 mt-4">
if issue.Severity == "warning" || issue.Severity == "info" {
<button
hx-post={ "/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve" }
hx-target={ "#issue-" + issue.ID }
hx-swap="outerHTML"
hx-confirm="Dismiss this issue?"
class="btn btn-secondary text-sm"
>
@Icon("close", "h-4 w-4")
Dismiss
</button>
}
</div>
</div>
}
</div> </div>
} }
</div> </div>
</main> <!-- Retry all: right of the errors row. Queues every issue
</body> currently on the page through the individual scanner,
</html> one at a time — deliberately NOT a whole-library rescan. -->
<button
@click="retryAll()"
:disabled="queueRunning"
class="btn btn-primary shrink-0"
>
<span x-show="!queueRunning" class="inline-flex items-center gap-2">
@Icon("refresh", "h-4 w-4")
<span>Retry all</span>
</span>
<span x-show="queueRunning" x-text="queueRunning ? (queueDone + ' / ' + queueTotal) : ''"></span>
</button>
</div>
}
if len(issues) == 0 {
<div class="card p-8 text-center">
<span class="grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("check-circle", "h-6 w-6")
</span>
<p class="text-sm" style="color: var(--text-secondary)">No processing issues found for this library.</p>
</div>
} else {
<!-- Issues List -->
<div class="space-y-4">
for _, issue := range issues {
<div class="card p-6" id={ "issue-" + issue.ID } data-issue-id={ issue.ID }>
<div class="flex justify-between items-start gap-4 mb-4">
<div class="flex-1 min-w-0">
<h4 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ issue.Title }</h4>
<p class="mb-3 text-sm" style="color: var(--text-secondary)">{ issue.IssueDescription }</p>
<div class="text-sm space-y-1" style="color: var(--text-secondary)">
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Type:</span> { issue.IssueType }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Format:</span> { issue.FormatGroup }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">File:</span> { issue.FilePath }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Library:</span> { issue.LibraryTypeName }</p>
</div>
</div>
<div class="ml-2 shrink-0">
if issue.Severity == "error" {
<span class="badge status-failed">{ issue.Severity }</span>
} else if issue.Severity == "warning" {
<span class="badge status-pending">{ issue.Severity }</span>
} else {
<span class="badge status-processing">{ issue.Severity }</span>
}
</div>
</div>
<div class="flex gap-3 mt-4">
<button
class="btn btn-primary text-sm issue-retry-btn"
@click={ "retryOne(" + JSONArg(issue.ID) + ")" }
:disabled="queueRunning"
>
@Icon("refresh", "h-4 w-4")
Retry
</button>
if issue.Severity == "warning" || issue.Severity == "info" {
<button
hx-post={ "/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve" }
hx-target={ "#issue-" + issue.ID }
hx-swap="outerHTML"
hx-confirm="Dismiss this issue?"
class="btn btn-secondary text-sm"
>
@Icon("close", "h-4 w-4")
Dismiss
</button>
}
</div>
</div>
}
</div>
}
</div>
} }
+262 -165
View File
@@ -59,7 +59,20 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -67,17 +80,59 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 { templ_7745c5c3_Err = ProcessingIssuesContent(libraryID, issues, stats).Render(ctx, templ_7745c5c3_Buffer)
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\">") 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if stats.ErrorCount > 0 { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -85,43 +140,16 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -130,25 +158,25 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if stats.InfoCount > 0 { if stats.WarningCount > 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);\">") 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -157,13 +185,48 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if len(issues) == 0 { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -171,198 +234,232 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, issue := range issues { for _, issue := range issues {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"card p-6\" id=\"") 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_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)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_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 { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string 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 { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string 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 { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string 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 { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string 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 { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></div></div><div class=\"ml-2 shrink-0\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Format:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 114, Col: 141}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">File:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 115, Col: 136}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Library:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 116, Col: 146}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</p></div></div><div class=\"ml-2 shrink-0\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if issue.Severity == "error" { 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, 33, "<span class=\"badge status-failed\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 85, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if issue.Severity == "warning" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"badge status-pending\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 87, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"badge status-processing\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 89, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></div><div class=\"flex gap-3 mt-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if issue.Severity == "warning" || issue.Severity == "info" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<button hx-post=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string 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 { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -370,22 +467,22 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Dismiss</button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "Dismiss</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div></main></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -218,7 +218,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</head> </head>
<body x-data="collections" x-init="initCollectionsPage()" class={ "theme-" + user.Theme }> <body x-data="collections" x-init="initCollectionsPage()" class={ "theme-" + user.Theme }>
@Header(user, "/collections") @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="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/collections" class="btn btn-secondary mb-4"> <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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+53 -38
View File
@@ -18,7 +18,7 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
</head> </head>
<body x-data="dashboard" x-init="initDashboard()" class={ "theme-" + user.Theme }> <body x-data="dashboard" x-init="initDashboard()" class={ "theme-" + user.Theme }>
@Header(user, "/dashboard") @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 <!-- OUTSIDE #collections-container on purpose: the switcher's
re-render wipes that container's innerHTML, and anything re-render wipes that container's innerHTML, and anything
server-rendered inside it dies on the first library server-rendered inside it dies on the first library
@@ -41,11 +41,6 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
</a> </a>
</div> </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"> <main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 pt-5 pb-8">
for _, section := range sections { for _, section := range sections {
@CollectionCarousel(section) @CollectionCarousel(section)
@@ -317,41 +312,56 @@ type BookStatsData struct {
FirstSession string FirstSession string
} }
// DashboardWeekWidget renders the collapsible "This Week" strip. The // weekWidgetComponent adapts the optional week stats into the
// collapsed summary row reads at a glance on every screen size; the // LibrarySwitcher's center slot. nil (stats unavailable or the stats
// expanded body holds the full grid. The details element re-fetches // service is off) = the slot stays empty, exactly as when the widget
// itself through htmx when the body sees a `week-refresh` event // used to be suppressed entirely.
// (dispatched by the library switcher), carrying the CURRENT func weekWidgetComponent(week *WeekStatsData, activeLibType string) templ.Component {
// #library-select value, so the widget tracks the switched library. 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) { templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
<details <details
id="week-widget" id="week-widget"
class="card group" class="group sm:relative"
hx-get="/dashboard/week-widget" hx-get="/dashboard/week-widget"
hx-trigger="week-refresh from:body" hx-trigger="week-refresh from:body"
hx-include="#library-select" hx-include="#library-select"
hx-swap="outerHTML" 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"> <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="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 shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
<span class="grid place-items-center h-6 w-6 rounded-md" style="background-color: var(--accent-muted); color: var(--accent);"> @Icon("chart", "h-4 w-4")
@Icon("chart", "h-4 w-4")
</span>
This Week
</span> </span>
<span class="text-sm font-semibold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</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" style="color: var(--text-secondary)">{ fmt.Sprintf("%d pages", week.WeekPages) }</span> <span class="text-sm font-semibold whitespace-nowrap hidden sm:inline" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d sessions", week.WeekSessions) }</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-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d books", week.WeekBooks) }</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 { 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"> @Icon("chevron-down", "h-4 w-4 shrink-0 transition-transform group-open:rotate-180")
<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>
</summary> </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"> <div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
if activeLibType == "audiobooks" { if activeLibType == "audiobooks" {
<div> <div>
@@ -387,16 +397,21 @@ templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
</div> </div>
} }
</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);">
Synced from your reading devices <p class="text-xs" style="color: var(--text-secondary)">
if week.CurrentStreak > 0 { Synced from your reading devices
if week.ActiveToday { if week.CurrentStreak > 0 {
{ fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak) } if week.ActiveToday {
} else { { fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak) }
{ fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak) } } else {
{ fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak) }
}
} }
} </p>
</p> <a href="/stats" class="inline-flex items-center gap-1 text-xs font-medium hover:underline shrink-0 whitespace-nowrap" style="color: var(--accent);">Full stats
@Icon("arrow-right", "h-4 w-4")
</a>
</div>
</div> </div>
</details> </details>
} }
File diff suppressed because it is too large. Load diff
+12 -2
View File
@@ -1,6 +1,13 @@
package templates 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 <div
class="app-subbar backdrop-blur border-b" 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);" 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="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"> <div class="flex items-center gap-3 min-w-0">
if title != "" { 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 { } else {
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library</label> <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> </select>
</div> </div>
if center != nil {
@center
}
if len(actions) > 0 { if len(actions) > 0 {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
for _, action := range actions { for _, action := range actions {
+29 -16
View File
@@ -8,7 +8,14 @@ package templates
import "github.com/a-h/templ" import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime" import templruntime "github.com/a-h/templ/runtime"
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) { 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 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 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 return templ_7745c5c3_Err
} }
if title != "" { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -64,7 +71,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData)) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -84,7 +91,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 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) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -97,7 +104,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -110,7 +117,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -128,7 +135,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 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) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -141,7 +148,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -154,7 +161,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -173,7 +180,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 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) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -186,7 +193,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -199,7 +206,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -217,7 +224,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 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) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -230,7 +237,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -243,7 +250,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -259,6 +266,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if len(actions) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"flex items-center gap-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"flex items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+18 -3
View File
@@ -68,7 +68,7 @@ func TestDashboardUnlinkedBanner(t *testing.T) {
} }
} }
// The unlinked-books page renders queue rows standalone. // The unlinked-books page renders queue rows standalone, both buckets.
func TestUnlinkedBooksPageRender(t *testing.T) { func TestUnlinkedBooksPageRender(t *testing.T) {
var b strings.Builder var b strings.Builder
user := User{ID: "u1", Timezone: "UTC"} user := User{ID: "u1", Timezone: "UTC"}
@@ -81,11 +81,26 @@ func TestUnlinkedBooksPageRender(t *testing.T) {
TitleFromDevice: "Some Sideloaded Book", TitleFromDevice: "Some Sideloaded Book",
LastSyncTime: "2026-10-18 12:00", LastSyncTime: "2026-10-18 12:00",
}} }}
if err := UnlinkedBooks(user, books).Render(context.Background(), &b); err != nil { ignored := []UnlinkedBookData{{
ProgressID: "ub2",
DeviceName: "KOReader Phone",
DeviceType: "koreader",
FilePath: "/sdcard/ignored.epub",
SHA256: "def456",
TitleFromDevice: "Some Dismissed Book",
LastSyncTime: "2026-10-18 12:00",
}}
if err := UnlinkedBooks(user, books, ignored).Render(context.Background(), &b); err != nil {
t.Fatalf("render: %v", err) t.Fatalf("render: %v", err)
} }
out := b.String() out := b.String()
for _, want := range []string{"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches"} { for _, want := range []string{
"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches",
// Dismiss/delete actions and the ignored recovery bucket.
"Some Dismissed Book", `hx-post="/unlinked-books/ub1/ignore"`,
`hx-delete="/unlinked-books/ub1"`, `hx-post="/unlinked-books/ub2/unignore"`,
"Ignored (1)",
} {
if !strings.Contains(out, want) { if !strings.Contains(out, want) {
t.Errorf("unlinked page missing %q", want) t.Errorf("unlinked page missing %q", want)
} }
+1 -1
View File
@@ -17,7 +17,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
</head> </head>
<body x-data="seriesPage" x-init="initSeriesPage()" class={ "theme-" + user.Theme }> <body x-data="seriesPage" x-init="initSeriesPage()" class={ "theme-" + user.Theme }>
@Header(user, "/series") @Header(user, "/series")
@LibrarySwitcher(libData, currentLibraryID, "") @LibrarySwitcher(libData, currentLibraryID, "", nil)
<div id="series-container"> <div id="series-container">
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8"> <main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6 flex items-center gap-3"> <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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+268 -187
View File
@@ -48,7 +48,7 @@ templ UnlinkedMatchCards(progressId string, matches []services.BookMatch) {
} }
} }
templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) { templ UnlinkedBooks(user User, unresolved []UnlinkedBookData, ignored []UnlinkedBookData) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -62,193 +62,274 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
<body class={ "theme-" + user.Theme } x-data="unlinkedBooks" x-init="setupEventDelegation()"> <body class={ "theme-" + user.Theme } x-data="unlinkedBooks" x-init="setupEventDelegation()">
@Header(user, "/unlinked") @Header(user, "/unlinked")
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8"> <main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8"> @UnlinkedBooksSection(unresolved, ignored)
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("sync", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Unlinked Books</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Resolve books that couldn't be automatically matched between devices</p>
</div>
if len(unlinkedBooks) > 0 {
<div class="card p-4 mb-6 flex flex-wrap justify-between items-center gap-3">
<div class="flex items-center gap-4">
<input type="checkbox" id="select-all-unlinked" @change="toggleAllUnlinked()" class="w-5 h-5"/>
<label for="select-all-unlinked" class="cursor-pointer text-sm font-medium" style="color: var(--text-primary)">Select All</label>
<span id="selected-count" class="text-sm" style="color: var(--text-secondary)">0 selected</span>
</div>
<div class="flex gap-2 flex-wrap">
<button @click="bulkAutoLink()" class="btn btn-secondary text-sm">
@Icon("sync", "h-4 w-4")
Auto-Link Selected (High Confidence)
</button>
<button @click="bulkGetSuggestions()" class="btn btn-secondary text-sm">
@Icon("search", "h-4 w-4")
Get Suggestions for Selected
</button>
<button @click="showBulkManualLink()" class="btn btn-primary text-sm">
@Icon("edit", "h-4 w-4")
Bulk Manual Link
</button>
</div>
</div>
}
<div id="unlinked-container" class="space-y-6">
if len(unlinkedBooks) == 0 {
<div class="card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">All Books Linked!</h3>
<p style="color: var(--text-secondary)">There are no unlinked books that need manual resolution.</p>
</div>
}
for _, book := range unlinkedBooks {
<div class="card p-6">
<div class="flex gap-6">
<div class="flex items-start pt-2">
<input type="checkbox" class="unlinked-checkbox w-5 h-5" data-progress-id={ book.ProgressID } data-title={ book.TitleFromDevice }/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-4">
<span class="grid place-items-center h-11 w-11 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
if book.DeviceType == "koreader" {
@Icon("book-open", "h-5 w-5")
} else if book.DeviceType == "kobo" {
@Icon("library", "h-5 w-5")
} else {
@Icon("device", "h-5 w-5")
}
</span>
<div class="min-w-0">
<h3 class="font-semibold text-lg truncate" style="color: var(--text-primary)">{ book.TitleFromDevice }</h3>
<p class="text-sm" style="color: var(--text-secondary)">
Device: { book.DeviceName } ({ book.DeviceType })
</p>
</div>
</div>
<div class="space-y-2 text-sm mb-4">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">File Path:</span>
<code class="chip font-mono">
@Icon("folder", "h-3.5 w-3.5")
{ book.FilePath }
</code>
</div>
if book.SHA256 != "" {
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">SHA-256:</span>
<code class="chip font-mono text-xs" title={ book.SHA256 }>{ book.SHA256 }</code>
</div>
}
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">Last Synced:</span>
<span style="color: var(--text-primary)">{ book.LastSyncTime }</span>
</div>
if book.ConfidenceScore > 0 {
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">Auto-Link Confidence:</span>
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">
{ book.ConfidenceScore }% confidence
</span>
</div>
}
</div>
<div class="mb-4 flex gap-2 flex-wrap">
<button
@click={ "searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")" }
class="btn btn-secondary text-sm"
>
@Icon("search", "h-4 w-4")
Find Potential Matches
</button>
<button
@click={ "showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")" }
class="btn btn-primary text-sm"
>
@Icon("edit", "h-4 w-4")
Manually Link
</button>
</div>
<div id={ "matches-" + book.ProgressID } class="hidden">
<div class="border-t pt-4 mt-2" style="border-color: var(--border);">
<h4 class="font-semibold mb-3" style="color: var(--text-primary)">Potential Matches</h4>
<div id={ "matches-list-" + book.ProgressID } class="space-y-3">
<p style="color: var(--text-secondary)">Click "Find Potential Matches" to search</p>
</div>
</div>
</div>
</div>
</div>
</div>
}
</div>
</main> </main>
<div id="manual-link-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);"> @unlinkedManualLinkModal()
<div class="card p-6 w-full max-w-md mx-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Manually Link Book</h2>
<button @click="hideManualLinkModal()" class="icon-btn" aria-label="Close">@Icon("close", "h-5 w-5")</button>
</div>
<input type="hidden" id="link-progress-id"/>
<input type="hidden" id="link-book-sha256"/>
<div class="mb-4">
<label for="link-book-title" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Book Title from Device</label>
<input
type="text"
id="link-book-title"
readonly
class="input"
style="color: var(--text-secondary);"
/>
</div>
<div class="mb-4">
<label for="link-search-input" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Search for Book to Link</label>
<div class="flex gap-2">
<input
type="text"
id="link-search-input"
placeholder="Search by title, author…"
class="input flex-1"
/>
<button
type="button"
@click="searchBooksForLink()"
class="btn btn-primary"
>
@Icon("search", "h-4 w-4")
Search
</button>
</div>
</div>
<div id="link-search-results" class="mb-4 max-h-48 overflow-y-auto space-y-2">
<p class="text-sm" style="color: var(--text-secondary)">Search for books to link</p>
</div>
<div class="mb-4">
<label for="link-confidence" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Confidence Score</label>
<input
type="number"
id="link-confidence"
min="0"
max="1"
step="0.1"
value="1.0"
class="input"
/>
<p class="text-xs mt-1" style="color: var(--text-secondary)">1.0 = manual override (highest confidence)</p>
</div>
<div class="flex justify-end gap-3">
<button type="button" @click="hideManualLinkModal()" class="btn btn-secondary">
Cancel
</button>
<button type="button" @click="confirmManualLink()" class="btn btn-primary">
@Icon("check", "h-4 w-4")
Link Book
</button>
</div>
</div>
</div>
</body> </body>
</html> </html>
} }
// UnlinkedBooksSection is the whole actionable page body: toolbar, the
// unresolved queue, and the collapsed ignored bucket. Every dismiss/
// delete/restore action re-renders this partial server-side (htmx
// hx-target="#unlinked-section" outerHTML) so counts and buckets can
// never drift from the database after an action.
templ UnlinkedBooksSection(unresolved []UnlinkedBookData, ignored []UnlinkedBookData) {
<div id="unlinked-section">
<div class="mb-8">
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("sync", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Unlinked Books</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Resolve books that couldn't be automatically matched between devices</p>
</div>
if len(unresolved) > 0 {
<div class="card p-4 mb-6 flex flex-wrap justify-between items-center gap-3">
<div class="flex items-center gap-4">
<input type="checkbox" id="select-all-unlinked" @change="toggleAllUnlinked()" class="w-5 h-5"/>
<label for="select-all-unlinked" class="cursor-pointer text-sm font-medium" style="color: var(--text-primary)">Select All</label>
<span id="selected-count" class="text-sm" style="color: var(--text-secondary)">0 selected</span>
</div>
<div class="flex gap-2 flex-wrap">
<button @click="bulkAutoLink()" class="btn btn-secondary text-sm">
@Icon("sync", "h-4 w-4")
Auto-Link Selected (High Confidence)
</button>
<button @click="bulkGetSuggestions()" class="btn btn-secondary text-sm">
@Icon("search", "h-4 w-4")
Get Suggestions for Selected
</button>
<button @click="showBulkManualLink()" class="btn btn-primary text-sm">
@Icon("edit", "h-4 w-4")
Bulk Manual Link
</button>
</div>
</div>
}
<div id="unlinked-container" class="space-y-6">
if len(unresolved) == 0 {
<div class="card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">All Books Linked!</h3>
<p style="color: var(--text-secondary)">There are no unlinked books that need manual resolution.</p>
</div>
}
for _, book := range unresolved {
@unlinkedBookCard(book, false)
}
</div>
if len(ignored) > 0 {
<details class="mt-8">
<summary class="cursor-pointer text-sm font-medium mb-3 select-none" style="color: var(--text-secondary)">
Ignored ({ fmt.Sprintf("%d", len(ignored)) }) — hidden from the queue and the dashboard badge
</summary>
<div class="space-y-6">
for _, book := range ignored {
@unlinkedBookCard(book, true)
}
</div>
</details>
}
</div>
}
// unlinkedBookCard renders one queue row. Ignored rows drop the checkbox
// and the match/link actions (they are dismissed) and offer Restore +
// Delete instead; all action buttons re-render the section server-side.
templ unlinkedBookCard(book UnlinkedBookData, ignored bool) {
<div class="card p-6">
<div class="flex gap-6">
if !ignored {
<div class="flex items-start pt-2">
<input type="checkbox" class="unlinked-checkbox w-5 h-5" data-progress-id={ book.ProgressID } data-title={ book.TitleFromDevice }/>
</div>
}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-4">
<span class="grid place-items-center h-11 w-11 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
if book.DeviceType == "koreader" {
@Icon("book-open", "h-5 w-5")
} else if book.DeviceType == "kobo" {
@Icon("library", "h-5 w-5")
} else {
@Icon("device", "h-5 w-5")
}
</span>
<div class="min-w-0">
<h3 class="font-semibold text-lg truncate" style="color: var(--text-primary)">{ book.TitleFromDevice }</h3>
<p class="text-sm" style="color: var(--text-secondary)">
Device: { book.DeviceName } ({ book.DeviceType })
</p>
</div>
</div>
<div class="space-y-2 text-sm mb-4">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">File Path:</span>
<code class="chip font-mono">
@Icon("folder", "h-3.5 w-3.5")
{ book.FilePath }
</code>
</div>
if book.SHA256 != "" {
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">SHA-256:</span>
<code class="chip font-mono text-xs" title={ book.SHA256 }>{ book.SHA256 }</code>
</div>
}
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">Last Synced:</span>
<span style="color: var(--text-primary)">{ book.LastSyncTime }</span>
</div>
if book.ConfidenceScore > 0 {
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">Auto-Link Confidence:</span>
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">
{ book.ConfidenceScore }% confidence
</span>
</div>
}
</div>
if ignored {
<div class="mb-4 flex gap-2 flex-wrap">
<button
hx-post={ "/unlinked-books/" + book.ProgressID + "/unignore" }
hx-target="#unlinked-section"
hx-swap="outerHTML"
class="btn btn-secondary text-sm"
>
@Icon("check", "h-4 w-4")
Restore
</button>
<button
hx-delete={ "/unlinked-books/" + book.ProgressID }
hx-confirm="Delete this ignored entry permanently?"
hx-target="#unlinked-section"
hx-swap="outerHTML"
class="btn btn-secondary text-sm"
>
@Icon("close", "h-4 w-4")
Delete
</button>
</div>
} else {
<div class="mb-4 flex gap-2 flex-wrap">
<button
@click={ "searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")" }
class="btn btn-secondary text-sm"
>
@Icon("search", "h-4 w-4")
Find Potential Matches
</button>
<button
@click={ "showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")" }
class="btn btn-primary text-sm"
>
@Icon("edit", "h-4 w-4")
Manually Link
</button>
<button
hx-post={ "/unlinked-books/" + book.ProgressID + "/ignore" }
hx-target="#unlinked-section"
hx-swap="outerHTML"
class="btn btn-secondary text-sm"
>
@Icon("close", "h-4 w-4")
Ignore
</button>
<button
hx-delete={ "/unlinked-books/" + book.ProgressID }
hx-confirm="Delete this entry? If the device syncs this file again, it will reappear here."
hx-target="#unlinked-section"
hx-swap="outerHTML"
class="btn btn-secondary text-sm"
>
@Icon("close", "h-4 w-4")
Delete
</button>
</div>
<div id={ "matches-" + book.ProgressID } class="hidden">
<div class="border-t pt-4 mt-2" style="border-color: var(--border);">
<h4 class="font-semibold mb-3" style="color: var(--text-primary)">Potential Matches</h4>
<div id={ "matches-list-" + book.ProgressID } class="space-y-3">
<p style="color: var(--text-secondary)">Click "Find Potential Matches" to search</p>
</div>
</div>
</div>
}
</div>
</div>
</div>
}
// unlinkedManualLinkModal holds the manual-link dialog markup (unchanged
// behavior; extracted so the section partial can be re-rendered on its own).
templ unlinkedManualLinkModal() {
<div id="manual-link-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md mx-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Manually Link Book</h2>
<button @click="hideManualLinkModal()" class="icon-btn" aria-label="Close">@Icon("close", "h-5 w-5")</button>
</div>
<input type="hidden" id="link-progress-id"/>
<input type="hidden" id="link-book-sha256"/>
<div class="mb-4">
<label for="link-book-title" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Book Title from Device</label>
<input
type="text"
id="link-book-title"
readonly
class="input"
style="color: var(--text-secondary);"
/>
</div>
<div class="mb-4">
<label for="link-search-input" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Search for Book to Link</label>
<div class="flex gap-2">
<input
type="text"
id="link-search-input"
placeholder="Search by title, author…"
class="input flex-1"
/>
<button
type="button"
@click="searchBooksForLink()"
class="btn btn-primary"
>
@Icon("search", "h-4 w-4")
Search
</button>
</div>
</div>
<div id="link-search-results" class="mb-4 max-h-48 overflow-y-auto space-y-2">
<p class="text-sm" style="color: var(--text-secondary)">Search for books to link</p>
</div>
<div class="mb-4">
<label for="link-confidence" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Confidence Score</label>
<input
type="number"
id="link-confidence"
min="0"
max="1"
step="0.1"
value="1.0"
class="input"
/>
<p class="text-xs mt-1" style="color: var(--text-secondary)">1.0 = manual override (highest confidence)</p>
</div>
<div class="flex justify-end gap-3">
<button type="button" @click="hideManualLinkModal()" class="btn btn-secondary">
Cancel
</button>
<button type="button" @click="confirmManualLink()" class="btn btn-primary">
@Icon("check", "h-4 w-4")
Link Book
</button>
</div>
</div>
</div>
}
+410 -155
View File
@@ -170,7 +170,7 @@ func UnlinkedMatchCards(progressId string, matches []services.BookMatch) templ.C
}) })
} }
func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component { func UnlinkedBooks(user User, unresolved []UnlinkedBookData, ignored []UnlinkedBookData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -221,7 +221,57 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><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, 16, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = UnlinkedBooksSection(unresolved, ignored).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = unlinkedManualLinkModal().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// UnlinkedBooksSection is the whole actionable page body: toolbar, the
// unresolved queue, and the collapsed ignored bucket. Every dismiss/
// delete/restore action re-renders this partial server-side (htmx
// hx-target="#unlinked-section" outerHTML) so counts and buckets can
// never drift from the database after an action.
func UnlinkedBooksSection(unresolved []UnlinkedBookData, ignored []UnlinkedBookData) 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_Var13 := templ.GetChildren(ctx)
if templ_7745c5c3_Var13 == nil {
templ_7745c5c3_Var13 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div id=\"unlinked-section\"><div class=\"mb-8\"><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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -229,12 +279,12 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Unlinked Books</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve books that couldn't be automatically matched between devices</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Unlinked Books</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve books that couldn't be automatically matched between devices</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(unlinkedBooks) > 0 { if len(unresolved) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"card p-4 mb-6 flex flex-wrap justify-between items-center gap-3\"><div class=\"flex items-center gap-4\"><input type=\"checkbox\" id=\"select-all-unlinked\" @change=\"toggleAllUnlinked()\" class=\"w-5 h-5\"> <label for=\"select-all-unlinked\" class=\"cursor-pointer text-sm font-medium\" style=\"color: var(--text-primary)\">Select All</label> <span id=\"selected-count\" class=\"text-sm\" style=\"color: var(--text-secondary)\">0 selected</span></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"bulkAutoLink()\" class=\"btn btn-secondary text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"card p-4 mb-6 flex flex-wrap justify-between items-center gap-3\"><div class=\"flex items-center gap-4\"><input type=\"checkbox\" id=\"select-all-unlinked\" @change=\"toggleAllUnlinked()\" class=\"w-5 h-5\"> <label for=\"select-all-unlinked\" class=\"cursor-pointer text-sm font-medium\" style=\"color: var(--text-primary)\">Select All</label> <span id=\"selected-count\" class=\"text-sm\" style=\"color: var(--text-secondary)\">0 selected</span></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"bulkAutoLink()\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -242,7 +292,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Auto-Link Selected (High Confidence)</button> <button @click=\"bulkGetSuggestions()\" class=\"btn btn-secondary text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "Auto-Link Selected (High Confidence)</button> <button @click=\"bulkGetSuggestions()\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -250,7 +300,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Get Suggestions for Selected</button> <button @click=\"showBulkManualLink()\" class=\"btn btn-primary text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Get Suggestions for Selected</button> <button @click=\"showBulkManualLink()\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -258,17 +308,17 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "Bulk Manual Link</button></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Bulk Manual Link</button></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div id=\"unlinked-container\" class=\"space-y-6\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div id=\"unlinked-container\" class=\"space-y-6\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(unlinkedBooks) == 0 { if len(unresolved) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -276,200 +326,332 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">All Books Linked!</h3><p style=\"color: var(--text-secondary)\">There are no unlinked books that need manual resolution.</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">All Books Linked!</h3><p style=\"color: var(--text-secondary)\">There are no unlinked books that need manual resolution.</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
for _, book := range unlinkedBooks { for _, book := range unresolved {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"card p-6\"><div class=\"flex gap-6\"><div class=\"flex items-start pt-2\"><input type=\"checkbox\" class=\"unlinked-checkbox w-5 h-5\" data-progress-id=\"") templ_7745c5c3_Err = unlinkedBookCard(book, false).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string }
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 111, Col: 100} return templ_7745c5c3_Err
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) if len(ignored) > 0 {
if templ_7745c5c3_Err != nil { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<details class=\"mt-8\"><summary class=\"cursor-pointer text-sm font-medium mb-3 select-none\" style=\"color: var(--text-secondary)\">Ignored (")
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" data-title=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(ignored)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 111, Col: 136} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 128, Col: 47}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\"></div><div class=\"flex-1 min-w-0\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ") — hidden from the queue and the dashboard badge</summary><div class=\"space-y-6\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if book.DeviceType == "koreader" { for _, book := range ignored {
templ_7745c5c3_Err = Icon("book-open", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = unlinkedBookCard(book, true).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if book.DeviceType == "kobo" {
templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = Icon("device", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span><div class=\"min-w-0\"><h3 class=\"font-semibold text-lg truncate\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></details>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string }
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 125, Col: 111} return templ_7745c5c3_Err
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) return nil
if templ_7745c5c3_Err != nil { })
return templ_7745c5c3_Err }
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Device: ") // unlinkedBookCard renders one queue row. Ignored rows drop the checkbox
// and the match/link actions (they are dismissed) and offer Restore +
// Delete instead; all action buttons re-render the section server-side.
func unlinkedBookCard(book UnlinkedBookData, ignored bool) 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_Var15 := templ.GetChildren(ctx)
if templ_7745c5c3_Var15 == nil {
templ_7745c5c3_Var15 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"card p-6\"><div class=\"flex gap-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !ignored {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div class=\"flex items-start pt-2\"><input type=\"checkbox\" class=\"unlinked-checkbox w-5 h-5\" data-progress-id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 127, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 148, Col: 96}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " (") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-title=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 127, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 148, Col: 132}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ")</p></div></div><div class=\"space-y-2 text-sm mb-4\"><div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">File Path:</span> <code class=\"chip font-mono\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("folder", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer) }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"flex-1 min-w-0\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.DeviceType == "koreader" {
templ_7745c5c3_Err = Icon("book-open", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string } else if book.DeviceType == "kobo" {
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath) templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 136, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</code></div>") } else {
templ_7745c5c3_Err = Icon("device", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if book.SHA256 != "" { }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">SHA-256:</span> <code class=\"chip font-mono text-xs\" title=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span><div class=\"min-w-0\"><h3 class=\"font-semibold text-lg truncate\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 142, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 163, Col: 106}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Device: ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 142, Col: 84} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 165, Col: 32}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</code></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " (")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} var templ_7745c5c3_Var20 string
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Last Synced:</span> <span style=\"color: var(--text-primary)\">") templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 165, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ")</p></div></div><div class=\"space-y-2 text-sm mb-4\"><div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">File Path:</span> <code class=\"chip font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("folder", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 174, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</code></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.SHA256 != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">SHA-256:</span> <code class=\"chip font-mono text-xs\" title=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 147, Col: 71} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 180, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</span></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.ConfidenceScore > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Auto-Link Confidence:</span> <span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 153, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "% confidence</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div><div class=\"mb-4 flex gap-2 flex-wrap\"><button @click=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")") templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 160, Col: 138} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 180, Col: 79}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" class=\"btn btn-secondary text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</code></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Last Synced:</span> <span style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 185, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.ConfidenceScore > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Auto-Link Confidence:</span> <span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 191, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "% confidence</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if ignored {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"mb-4 flex gap-2 flex-wrap\"><button hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("/unlinked-books/" + book.ProgressID + "/unignore")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 199, Col: 67}
}
_, 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, 52, "\" hx-target=\"#unlinked-section\" hx-swap=\"outerHTML\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check", "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, 53, "Restore</button> <button hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("/unlinked-books/" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 208, Col: 55}
}
_, 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, 54, "\" hx-confirm=\"Delete this ignored entry permanently?\" hx-target=\"#unlinked-section\" hx-swap=\"outerHTML\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "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, 55, "Delete</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"mb-4 flex gap-2 flex-wrap\"><button @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 221, Col: 134}
}
_, 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, 57, "\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -477,20 +659,20 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "Find Potential Matches</button> <button @click=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "Find Potential Matches</button> <button @click=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")") templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue("showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 167, Col: 114} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 228, Col: 110}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" class=\"btn btn-primary text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -498,38 +680,111 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "Manually Link</button></div><div id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Manually Link</button> <button hx-post=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-" + book.ProgressID) templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("/unlinked-books/" + book.ProgressID + "/ignore")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 174, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 235, Col: 65}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" class=\"hidden\"><div class=\"border-t pt-4 mt-2\" style=\"border-color: var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Potential Matches</h4><div id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" hx-target=\"#unlinked-section\" hx-swap=\"outerHTML\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var26 string templ_7745c5c3_Err = Icon("close", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-list-" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 177, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"space-y-3\"><p style=\"color: var(--text-secondary)\">Click \"Find Potential Matches\" to search</p></div></div></div></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "Ignore</button> <button hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("/unlinked-books/" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 244, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" hx-confirm=\"Delete this entry? If the device syncs this file again, it will reappear here.\" hx-target=\"#unlinked-section\" hx-swap=\"outerHTML\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "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, 64, "Delete</button></div><div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 254, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"hidden\"><div class=\"border-t pt-4 mt-2\" style=\"border-color: var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Potential Matches</h4><div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-list-" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 257, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" class=\"space-y-3\"><p style=\"color: var(--text-secondary)\">Click \"Find Potential Matches\" to search</p></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div></main><div id=\"manual-link-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Manually Link Book</h2><button @click=\"hideManualLinkModal()\" class=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// unlinkedManualLinkModal holds the manual-link dialog markup (unchanged
// behavior; extracted so the section partial can be re-rendered on its own).
func unlinkedManualLinkModal() 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_Var34 := templ.GetChildren(ctx)
if templ_7745c5c3_Var34 == nil {
templ_7745c5c3_Var34 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div id=\"manual-link-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Manually Link Book</h2><button @click=\"hideManualLinkModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -537,7 +792,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</button></div><input type=\"hidden\" id=\"link-progress-id\"> <input type=\"hidden\" id=\"link-book-sha256\"><div class=\"mb-4\"><label for=\"link-book-title\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Book Title from Device</label> <input type=\"text\" id=\"link-book-title\" readonly class=\"input\" style=\"color: var(--text-secondary);\"></div><div class=\"mb-4\"><label for=\"link-search-input\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search for Book to Link</label><div class=\"flex gap-2\"><input type=\"text\" id=\"link-search-input\" placeholder=\"Search by title, author…\" class=\"input flex-1\"> <button type=\"button\" @click=\"searchBooksForLink()\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</button></div><input type=\"hidden\" id=\"link-progress-id\"> <input type=\"hidden\" id=\"link-book-sha256\"><div class=\"mb-4\"><label for=\"link-book-title\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Book Title from Device</label> <input type=\"text\" id=\"link-book-title\" readonly class=\"input\" style=\"color: var(--text-secondary);\"></div><div class=\"mb-4\"><label for=\"link-search-input\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search for Book to Link</label><div class=\"flex gap-2\"><input type=\"text\" id=\"link-search-input\" placeholder=\"Search by title, author…\" class=\"input flex-1\"> <button type=\"button\" @click=\"searchBooksForLink()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -545,7 +800,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "Search</button></div></div><div id=\"link-search-results\" class=\"mb-4 max-h-48 overflow-y-auto space-y-2\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Search for books to link</p></div><div class=\"mb-4\"><label for=\"link-confidence\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Confidence Score</label> <input type=\"number\" id=\"link-confidence\" min=\"0\" max=\"1\" step=\"0.1\" value=\"1.0\" class=\"input\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">1.0 = manual override (highest confidence)</p></div><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"hideManualLinkModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"confirmManualLink()\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Search</button></div></div><div id=\"link-search-results\" class=\"mb-4 max-h-48 overflow-y-auto space-y-2\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Search for books to link</p></div><div class=\"mb-4\"><label for=\"link-confidence\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Confidence Score</label> <input type=\"number\" id=\"link-confidence\" min=\"0\" max=\"1\" step=\"0.1\" value=\"1.0\" class=\"input\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">1.0 = manual override (highest confidence)</p></div><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"hideManualLinkModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"confirmManualLink()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -553,7 +808,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "Link Book</button></div></div></div></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "Link Book</button></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+12
View File
@@ -383,6 +383,18 @@ func hxValsFolderDelete(folderPath string) string {
return "js:" + string(b) 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) — // fmtSecondsHMS renders seconds as H:MM:SS (or M:SS under an hour) —
// the audio progress display shape shared with the web player. // the audio progress display shape shared with the web player.
func fmtSecondsHMS(seconds float64) string { 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) { if (!scanListenerRegistered) {
scanListenerRegistered = true; scanListenerRegistered = true;
window.addEventListener("bookhoard:scan-complete", async () => { window.addEventListener("bookhoard:scan-complete", async () => {
+1
View File
@@ -22,6 +22,7 @@ import "./library-switcher";
import "./linking"; import "./linking";
import "./login"; import "./login";
import "./password_validation"; import "./password_validation";
import "./processing_issues";
import "./profile"; import "./profile";
import "./profile-modal"; import "./profile-modal";
import "./register"; 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"] { .nav-group-items[data-collapsed="true"] {
display: none; 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: } *,: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