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