Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b122f2481 | ||
|
|
f1e4b2a603 | ||
|
|
795225f097 | ||
|
|
9f39e2efd8 | ||
|
|
7b9e8e4fc2 | ||
|
|
4c43caa639 | ||
|
|
fd9292e6f2 | ||
|
|
ffbcc53083 | ||
|
|
fb1645c90c | ||
|
|
96c1228805 | ||
|
|
14285ff314 | ||
|
|
e5d4744977 | ||
|
|
b462c7fb44 | ||
|
|
59eddc1386 | ||
|
|
eaf1b2c8dd | ||
|
|
61e5f47ec0 | ||
|
|
d1f9ad3b25 | ||
|
|
79f4d668f1 | ||
|
|
c71ef4af85 | ||
|
|
7c9e561e68 | ||
|
|
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
|
Project-wide context lives in the sibling repos' AGENTS.md files
|
||||||
(../bookhoard-app/AGENTS.md is the Android client's).
|
(../bookhoard-app/AGENTS.md is the Android client's).
|
||||||
|
|
||||||
|
## Commit as each unit of work completes — ALWAYS apply
|
||||||
|
|
||||||
|
Commit when a logically complete piece of the project is done — a fix,
|
||||||
|
a feature slice, a schema change, a docs/test batch — before moving on
|
||||||
|
to the next piece. Do not hold a whole session's work for one giant
|
||||||
|
end-of-session commit, and never push uncommitted work aside until the
|
||||||
|
tree is clean.
|
||||||
|
|
||||||
|
- **One commit per related edit.** If the changed files serve more than
|
||||||
|
one purpose, they are separate commits; only share a commit when you
|
||||||
|
are sure every file in it is the same related edit. Generated
|
||||||
|
artifacts (templ codegen, sqlc output, vite/tailwind build output,
|
||||||
|
bruno/openapi mirrors) commit with the source change that produced
|
||||||
|
them.
|
||||||
|
- **Detailed messages**, in the repo's conventional-commit style
|
||||||
|
(`fix(scanner): …`, `feat(issues): …`) with a body that says WHY the
|
||||||
|
change exists, what behavior changed, and any precedent it follows —
|
||||||
|
not just what the diff shows. The body is how the next agent (and
|
||||||
|
git history) arbitrates "test stale vs code changed".
|
||||||
|
- **Ordering**: stack commits so each tree compiles where practical
|
||||||
|
(a service before the handler that calls it, a guard before the
|
||||||
|
feature that relies on it).
|
||||||
|
- **Committing ≠ pushing.** Commits are local bookkeeping and land as
|
||||||
|
work completes; pushing (once, after the last commit) is the author's
|
||||||
|
call. Never push half-finished units just because they were committed.
|
||||||
|
|
||||||
## Rendering ownership (templ vs TypeScript) — ALWAYS apply
|
## Rendering ownership (templ vs TypeScript) — ALWAYS apply
|
||||||
|
|
||||||
The point of templ + htmx + Alpine is: the server renders the UI, and
|
The point of templ + htmx + Alpine is: the server renders the UI, and
|
||||||
@@ -54,7 +80,11 @@ refuses, **silently at build/test time**, two authoring patterns:
|
|||||||
guard means recomputing the hash (the failing test prints it); adding
|
guard means recomputing the hash (the failing test prints it); adding
|
||||||
a *second* inline script means hashing it too and extending
|
a *second* inline script means hashing it too and extending
|
||||||
`cspSanctionedInlineScripts` in `templates/csp_source_test.go`.
|
`cspSanctionedInlineScripts` in `templates/csp_source_test.go`.
|
||||||
Everything else goes in a bundled `web/src` module.
|
Everything else goes in a bundled `web/src` module. This includes
|
||||||
|
partials emitted from Go handler strings — those use data attributes
|
||||||
|
+ `web/src/auth_forms.ts` (post-swap redirect/stash) or htmx's
|
||||||
|
`HX-Redirect`/`HX-Refresh` headers; `internal/middleware`'s
|
||||||
|
Go-source scan is the tripwire for that vector.
|
||||||
- **No native `on*=` handler attributes** (`onclick=`, `onerror=`,
|
- **No native `on*=` handler attributes** (`onclick=`, `onerror=`,
|
||||||
`onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline
|
`onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline
|
||||||
handlers never go through the hash path; hash-mode CSP refuses them
|
handlers never go through the hash path; hash-mode CSP refuses them
|
||||||
|
|||||||
@@ -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:
|
info:
|
||||||
name: Scan Library
|
name: Scan Library
|
||||||
type: http
|
type: http
|
||||||
seq: 18
|
seq: 19
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Set Library Visibility
|
name: Set Library Visibility
|
||||||
type: http
|
type: http
|
||||||
seq: 19
|
seq: 20
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Library
|
name: Update Library
|
||||||
type: http
|
type: http
|
||||||
seq: 20
|
seq: 21
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Scan Settings
|
name: Update Scan Settings
|
||||||
type: http
|
type: http
|
||||||
seq: 21
|
seq: 22
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Regression tests for the archive reconciliation pass: a multi-folder
|
||||||
|
// library must be reconciled against the UNION of its folders' walks, not
|
||||||
|
// once per folder. The old per-folder loop listed every library item
|
||||||
|
// against each single folder's walk, so with two registered folders every
|
||||||
|
// item living under the other folder was flagged "missing (1/2)" on every
|
||||||
|
// scan (prod: 743/743 audiobooks stuck at count 1 — unarchivable only
|
||||||
|
// because each scan's walk kept clearing them), and with three or more
|
||||||
|
// folders the crossed marks would have reached the archive threshold
|
||||||
|
// mid-pass and archived real content. Folder-book roots (directory
|
||||||
|
// file_path) must also count as present across folder boundaries — the
|
||||||
|
// file walk never emits a directory.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/services"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scanTestFolders runs a synchronous scan over several folders — the
|
||||||
|
// multi-folder shape the archive pass must get right (scanTestFolder
|
||||||
|
// covers the single-folder case).
|
||||||
|
func scanTestFolders(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folders []string) {
|
||||||
|
t.Helper()
|
||||||
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
|
defer func() { _ = scanner.Close() }()
|
||||||
|
scanner.SetAdminID(adminID)
|
||||||
|
require.NoError(t, scanner.SetFolders(folders, false))
|
||||||
|
require.NoError(t, scanner.ScanFolders(context.Background()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArchivePassMultiFolderLibraryKeepsEverythingPresent(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
libID, folderA := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Archive MultiFolder %d", time.Now().UnixNano()), "audiobooks")
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) }) // after setup.Close defer (LIFO): pool still open
|
||||||
|
|
||||||
|
// Second registered folder in the SAME library — the shape the
|
||||||
|
// per-folder reconciliation got wrong.
|
||||||
|
folderB := t.TempDir()
|
||||||
|
_, err := setup.DB.AddLibraryFolder(context.Background(), database.AddLibraryFolderParams{
|
||||||
|
LibraryID: libID,
|
||||||
|
FolderPath: folderB,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
admin, _ := moveTestAdminID(t, setup)
|
||||||
|
|
||||||
|
// Folder A holds a lone single-file book; folder B holds a
|
||||||
|
// folder-of-tracks book whose file_path is a DIRECTORY.
|
||||||
|
writeTestTrack(t, filepath.Join(folderA, "Author A", "Lone Book", "lonely.mp3"))
|
||||||
|
writeTestTrack(t, filepath.Join(folderB, "Author B", "Folder Book", "01 First.mp3"))
|
||||||
|
writeTestTrack(t, filepath.Join(folderB, "Author B", "Folder Book", "02 Second.mp3"))
|
||||||
|
|
||||||
|
// Two consecutive scans: the first proves no flags are raised, the
|
||||||
|
// second proves the flag/walk bounce (clear → re-flag) is gone too.
|
||||||
|
for scanNo := 1; scanNo <= 2; scanNo++ {
|
||||||
|
scanTestFolders(t, setup, admin, []string{folderA, folderB})
|
||||||
|
|
||||||
|
items := listLibraryAudioItems(t, setup, libID)
|
||||||
|
var singleFile, folderBook bool
|
||||||
|
for _, item := range items {
|
||||||
|
assert.False(t, item.ArchivedAt.Valid, "scan %d: item %q must not be archived", scanNo, item.FilePath)
|
||||||
|
assert.Equal(t, int32(0), item.MissingScanCount, "scan %d: item %q must not be flagged missing", scanNo, item.FilePath)
|
||||||
|
switch item.FilePath {
|
||||||
|
case "Author A/Lone Book/lonely.mp3":
|
||||||
|
singleFile = true
|
||||||
|
case "Author B/Folder Book":
|
||||||
|
folderBook = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.True(t, singleFile, "scan %d: single-file book must exist", scanNo)
|
||||||
|
require.True(t, folderBook, "scan %d: folder book must exist at its directory path", scanNo)
|
||||||
|
require.Len(t, items, 2, "scan %d: exactly the two books, no per-track strays", scanNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -411,7 +412,12 @@ func TestAudiobookTagFallbacks(t *testing.T) {
|
|||||||
require.Len(t, item.Narrators, 1, "composer tag fills narrators (Audible convention)")
|
require.Len(t, item.Narrators, 1, "composer tag fills narrators (Audible convention)")
|
||||||
require.Equal(t, "Tag Narrator", item.Narrators[0])
|
require.Equal(t, "Tag Narrator", item.Narrators[0])
|
||||||
require.Equal(t, "Tag Series", item.Series.String, "grouping tag fills series")
|
require.Equal(t, "Tag Series", item.Series.String, "grouping tag fills series")
|
||||||
require.Equal(t, "Audiobook", item.Genre.String, "genre tag fills genre")
|
// 885d6140 demoted format words ("Audiobook") to tags-only: the
|
||||||
|
// genre column stays empty and the raw candidate lands in tags.
|
||||||
|
// (The commit message declares this test's update; the edit itself
|
||||||
|
// never landed — pinned to the demoted contract now.)
|
||||||
|
require.Empty(t, item.Genre.String, "demoted format words never win the genre column")
|
||||||
|
assert.Contains(t, item.Tags, "Audiobook", "the demoted candidate lands as a tag")
|
||||||
require.False(t, item.DatePublished.Time.IsZero(), "date tag fills publish date")
|
require.False(t, item.DatePublished.Time.IsZero(), "date tag fills publish date")
|
||||||
require.Equal(t, 2020, item.DatePublished.Time.Year())
|
require.Equal(t, 2020, item.DatePublished.Time.Year())
|
||||||
// ASIN/abridged/subtitle arrive only through Audible freeform atoms,
|
// ASIN/abridged/subtitle arrive only through Audible freeform atoms,
|
||||||
|
|||||||
@@ -452,9 +452,13 @@ func TestContractPin_KoboSyncFromServer(t *testing.T) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// bookInfoKeys is the BookInfo JSON every card surface emits.
|
// bookInfoKeys is the BookInfo JSON every card surface emits.
|
||||||
|
// 2026-10-27 (search-progress batch): + viewer_percentage (nil = no
|
||||||
|
// viewer data; only the search-unified adapter populates it today)
|
||||||
|
// and + library_name (same deal). docs/contract_pins.md changelog.
|
||||||
var bookInfoKeys = []string{
|
var bookInfoKeys = []string{
|
||||||
"media_item_id", "title", "author", "cover_image_path",
|
"media_item_id", "title", "author", "cover_image_path",
|
||||||
"has_conflict", "format_group", "badge",
|
"has_conflict", "format_group", "badge",
|
||||||
|
"viewer_percentage", "library_name",
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionURL(setup *TestServerSetup, id string) string {
|
func collectionURL(setup *TestServerSetup, id string) string {
|
||||||
@@ -667,6 +671,12 @@ func TestContractPin_SearchJSON(t *testing.T) {
|
|||||||
// (docs/contract_pins.md changelog).
|
// (docs/contract_pins.md changelog).
|
||||||
"subtitle", "explicit", "authors", "authors_search",
|
"subtitle", "explicit", "authors", "authors_search",
|
||||||
"publishers", "publishers_search", "narrators_search",
|
"publishers", "publishers_search", "narrators_search",
|
||||||
|
// 2026-10-27 (search-progress batch): + viewer_percentage —
|
||||||
|
// the requester's reading_progress.percentage (LEFT JOIN on
|
||||||
|
// the same user_id param the visibility join binds), null
|
||||||
|
// when they never opened the book (docs/contract_pins.md
|
||||||
|
// changelog).
|
||||||
|
"viewer_percentage",
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
|
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMetadataWriteToFile pins the write-to-file endpoint (2026-10-27):
|
||||||
|
// POST /api/media-items/:id/write-metadata (admin only) persists the
|
||||||
|
// item's CURRENT database metadata into the artifact — embedded OPF for
|
||||||
|
// EPUBs, a Calibre-style sidecar for formats that can't carry metadata
|
||||||
|
// — then re-extracts. Sidecar naming: metadata.opf when the item is the
|
||||||
|
// only book in its directory, <basename>.opf otherwise (one book's
|
||||||
|
// sidecar must never poison dir-mates).
|
||||||
|
//
|
||||||
|
// Fixture files live under /app/uploads (the container's library
|
||||||
|
// folder) and are removed on cleanup; the test library delete
|
||||||
|
// cascades the items.
|
||||||
|
func TestMetadataWriteToFile(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Metadata Write Library", true)
|
||||||
|
uploadsDir := "/app/uploads"
|
||||||
|
|
||||||
|
insertItem := func(relPath, title string) string {
|
||||||
|
var itemID string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, 'ebook', 'application/epub+zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
|
||||||
|
libID, title, relPath,
|
||||||
|
).Scan(&itemID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return itemID
|
||||||
|
}
|
||||||
|
|
||||||
|
writeEPUB := func(absPath, title string) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(absPath), 0o755))
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := zip.NewWriter(&buf)
|
||||||
|
mimetype, _ := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
|
||||||
|
_, _ = mimetype.Write([]byte("application/epub+zip"))
|
||||||
|
container, _ := w.Create("META-INF/container.xml")
|
||||||
|
_, _ = container.Write([]byte(`<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`))
|
||||||
|
opf, _ := w.Create("OEBPS/content.opf")
|
||||||
|
_, _ = opf.Write([]byte(fmt.Sprintf(`<?xml version="1.0"?><package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title id="t1">%s</dc:title><dc:creator>Original Author</dc:creator><dc:identifier id="pub-id">urn:uuid:abcd</dc:identifier></metadata><manifest><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="c1"/></spine></package>`, title)))
|
||||||
|
ch, _ := w.Create("OEBPS/c1.xhtml")
|
||||||
|
_, _ = ch.Write([]byte("<html><body>x</body></html>"))
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
require.NoError(t, os.WriteFile(absPath, buf.Bytes(), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
callWrite := func(token, itemID string) (int, map[string]interface{}) {
|
||||||
|
req, _ := http.NewRequest("POST", fmt.Sprintf("%s/api/media-items/%s/write-metadata", setup.Server.URL, itemID), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var body map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
return resp.StatusCode, body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit the item's metadata the way the editor does (PUT), then ask
|
||||||
|
// the endpoint to persist it.
|
||||||
|
editItem := func(itemID string, fields map[string]interface{}) {
|
||||||
|
body, _ := json.Marshal(fields)
|
||||||
|
req, _ := http.NewRequest("PUT", fmt.Sprintf("%s/api/media-items/%s", setup.Server.URL, itemID), bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, "PUT should succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("non-admin is forbidden", func(t *testing.T) {
|
||||||
|
rel := fmt.Sprintf("test-metadata-write-%d/forbidden/book.epub", os.Getpid())
|
||||||
|
abs := filepath.Join(uploadsDir, rel)
|
||||||
|
writeEPUB(abs, "Forbidden Book")
|
||||||
|
itemID := insertItem(rel, "Forbidden Book")
|
||||||
|
code, _ := callWrite(setup.RegularToken, itemID)
|
||||||
|
assert.Equal(t, http.StatusForbidden, code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file errors without sidecar litter", func(t *testing.T) {
|
||||||
|
itemID := insertItem(fmt.Sprintf("test-metadata-write-%d/ghost/book.epub", os.Getpid()), "Ghost Book")
|
||||||
|
code, body := callWrite(setup.Token, itemID)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, code)
|
||||||
|
assert.Contains(t, body["error"], "missing on disk")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EPUB metadata is embedded and rescan keeps it", func(t *testing.T) {
|
||||||
|
rel := fmt.Sprintf("test-metadata-write-%d/solo/book.epub", os.Getpid())
|
||||||
|
abs := filepath.Join(uploadsDir, rel)
|
||||||
|
writeEPUB(abs, "Original EPUB Title")
|
||||||
|
itemID := insertItem(rel, "Original EPUB Title")
|
||||||
|
|
||||||
|
editItem(itemID, map[string]interface{}{
|
||||||
|
"title": "Embedded New Title",
|
||||||
|
"authors": []string{"Embedded Author"},
|
||||||
|
"series": "Embedded Series",
|
||||||
|
"series_number": 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
code, body := callWrite(setup.Token, itemID)
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, "embedded-opf", body["metadata_target"])
|
||||||
|
|
||||||
|
// The zip on disk now carries the new values — verified by
|
||||||
|
// PARSING the rewritten OPF (not string matching), with the
|
||||||
|
// ORIGINAL unique-identifier surviving the splice.
|
||||||
|
reader, err := zip.OpenReader(abs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var opfContent []byte
|
||||||
|
for _, f := range reader.File {
|
||||||
|
if f.Name == "OEBPS/content.opf" {
|
||||||
|
rc, err := f.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
opfContent, err = io.ReadAll(rc)
|
||||||
|
require.NoError(t, rc.Close())
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.Close()
|
||||||
|
|
||||||
|
var opf struct {
|
||||||
|
Metadata struct {
|
||||||
|
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||||
|
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||||
|
Metas []struct {
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Content string `xml:"content,attr"`
|
||||||
|
} `xml:"meta"`
|
||||||
|
} `xml:"metadata"`
|
||||||
|
}
|
||||||
|
require.NoError(t, xml.Unmarshal(opfContent, &opf))
|
||||||
|
require.NotEmpty(t, opf.Metadata.Titles, "dc:title present in written OPF")
|
||||||
|
assert.Equal(t, "Embedded New Title", opf.Metadata.Titles[0])
|
||||||
|
assert.NotContains(t, opf.Metadata.Titles, "Original EPUB Title", "old title replaced")
|
||||||
|
require.NotEmpty(t, opf.Metadata.Creators)
|
||||||
|
var seriesMeta string
|
||||||
|
for _, m := range opf.Metadata.Metas {
|
||||||
|
if m.Name == "calibre:series" {
|
||||||
|
seriesMeta = m.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, "Embedded Series", seriesMeta)
|
||||||
|
assert.Contains(t, string(opfContent), "urn:uuid:abcd", "original identifier preserved for unique-identifier")
|
||||||
|
|
||||||
|
// The inline rescan re-extracted from the file: the DB title
|
||||||
|
// still reads the written value.
|
||||||
|
var title string
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
||||||
|
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
|
||||||
|
assert.Equal(t, "Embedded New Title", title)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("single-book dir gets a Calibre-style metadata.opf sidecar", func(t *testing.T) {
|
||||||
|
rel := fmt.Sprintf("test-metadata-write-%d/alone/book.pdf", os.Getpid())
|
||||||
|
abs := filepath.Join(uploadsDir, rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(abs, []byte("%PDF-1.4 fake"), 0o644))
|
||||||
|
|
||||||
|
var itemID string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, 'Sidecar Book', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
|
||||||
|
libID, rel,
|
||||||
|
).Scan(&itemID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
editItem(itemID, map[string]interface{}{"title": "Sidecar New Title"})
|
||||||
|
|
||||||
|
code, body := callWrite(setup.Token, itemID)
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, "sidecar-opf", body["metadata_target"])
|
||||||
|
|
||||||
|
sidecar := filepath.Join(filepath.Dir(abs), "metadata.opf")
|
||||||
|
data, err := os.ReadFile(sidecar)
|
||||||
|
require.NoError(t, err, "metadata.opf must exist next to a lone book")
|
||||||
|
|
||||||
|
// Parse the sidecar like the scanner does: title must be present
|
||||||
|
// as a dc:title ELEMENT, not just a substring.
|
||||||
|
var opf struct {
|
||||||
|
Metadata struct {
|
||||||
|
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||||
|
} `xml:"metadata"`
|
||||||
|
}
|
||||||
|
require.NoError(t, xml.Unmarshal(data, &opf))
|
||||||
|
require.NotEmpty(t, opf.Metadata.Titles)
|
||||||
|
assert.Equal(t, "Sidecar New Title", opf.Metadata.Titles[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("shared dir falls back to <basename>.opf", func(t *testing.T) {
|
||||||
|
// PDFs can't carry metadata, so they take the sidecar path —
|
||||||
|
// and with two books in one directory, the sidecar must be
|
||||||
|
// per-file or A's write would poison B's next scan.
|
||||||
|
dir := fmt.Sprintf("test-metadata-write-%d/shared", os.Getpid())
|
||||||
|
relA, relB := dir+"/a.pdf", dir+"/b.pdf"
|
||||||
|
absA, absB := filepath.Join(uploadsDir, relA), filepath.Join(uploadsDir, relB)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(absA), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(absA, []byte("%PDF-1.4 fake a"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(absB, []byte("%PDF-1.4 fake b"), 0o644))
|
||||||
|
|
||||||
|
var itemA string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, 'Book A', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
|
||||||
|
libID, relA,
|
||||||
|
).Scan(&itemA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var itemB string
|
||||||
|
err = setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, 'Book B', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
|
||||||
|
libID, relB,
|
||||||
|
).Scan(&itemB)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
editItem(itemA, map[string]interface{}{"title": "Book A Rewritten"})
|
||||||
|
|
||||||
|
code, body := callWrite(setup.Token, itemA)
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, "sidecar-opf", body["metadata_target"])
|
||||||
|
|
||||||
|
// a.opf carries A's metadata; NO dir-scoped metadata.opf
|
||||||
|
// may appear (it would poison B's next scan), and B gets no
|
||||||
|
// sidecar from A's write. The name strips the extension,
|
||||||
|
// matching the scanner's {basename}.jpg cover convention.
|
||||||
|
data, err := os.ReadFile(filepath.Join(uploadsDir, dir, "a.opf"))
|
||||||
|
require.NoError(t, err, "shared dir must use <basename>.opf")
|
||||||
|
assert.Contains(t, string(data), "Book A Rewritten")
|
||||||
|
_, err = os.Stat(filepath.Join(uploadsDir, dir, "metadata.opf"))
|
||||||
|
assert.True(t, os.IsNotExist(err), "no dir-scoped sidecar over a multi-book folder")
|
||||||
|
_, err = os.Stat(filepath.Join(uploadsDir, dir, "b.opf"))
|
||||||
|
assert.True(t, os.IsNotExist(err), "B gets no sidecar from A's write")
|
||||||
|
_ = itemB
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("audio tags rewrite round-trips for every container", func(t *testing.T) {
|
||||||
|
// Each single-file container gets its own generated fixture
|
||||||
|
// (ffmpeg at test runtime — no binary media in git), its own
|
||||||
|
// write, and an INDEPENDENT verification pass: ffprobe the
|
||||||
|
// rewritten file and assert every mapped key landed. This is the
|
||||||
|
// "check the file's metadata yourself" contract per format:
|
||||||
|
//
|
||||||
|
// album/artist/composer/grouping/date/genre → all four
|
||||||
|
// subtitle → mp3 + flac only
|
||||||
|
// (the mp4 muxer
|
||||||
|
// drops it)
|
||||||
|
cases := []struct {
|
||||||
|
ext string
|
||||||
|
audioCodec string
|
||||||
|
wantSubtitle bool
|
||||||
|
}{
|
||||||
|
{ext: ".mp3", audioCodec: "libmp3lame", wantSubtitle: true},
|
||||||
|
{ext: ".flac", audioCodec: "flac", wantSubtitle: true},
|
||||||
|
{ext: ".m4a", audioCodec: "aac", wantSubtitle: false},
|
||||||
|
{ext: ".m4b", audioCodec: "aac", wantSubtitle: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.ext, func(t *testing.T) {
|
||||||
|
rel := fmt.Sprintf("test-metadata-write-%d/audio-%s/book%s", os.Getpid(), strings.TrimPrefix(tc.ext, "."), tc.ext)
|
||||||
|
abs := filepath.Join(uploadsDir, rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
|
||||||
|
gen := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||||
|
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
|
||||||
|
"-t", "2", "-c:a", tc.audioCodec,
|
||||||
|
"-metadata", "title=Generated Fixture",
|
||||||
|
"-metadata", "album=Generated Fixture",
|
||||||
|
abs)
|
||||||
|
if out, err := gen.CombinedOutput(); err != nil {
|
||||||
|
t.Skipf("fixture generation failed for %s (%v): %s", tc.ext, err, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemID string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, 'Generated Fixture', $2, 'audiobook', $3, 100, NOW(), NOW(), NOW()) RETURNING id`,
|
||||||
|
libID, rel, "audio/"+strings.TrimPrefix(tc.ext, "."),
|
||||||
|
).Scan(&itemID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Follow the real editor flow: PUT first (records
|
||||||
|
// metadata_overrides, so the inline rescan keeps the
|
||||||
|
// edited values instead of folder-structure derivations).
|
||||||
|
editItem(itemID, map[string]interface{}{
|
||||||
|
"title": "Audio New Title",
|
||||||
|
"subtitle": "Audio Sub",
|
||||||
|
"authors": []string{"Audio Author"},
|
||||||
|
"narrators": []string{"Narrator Person"},
|
||||||
|
"series": "Audio Series",
|
||||||
|
"series_number": 1.5,
|
||||||
|
"date_published": "1953-04-16",
|
||||||
|
"tags": []string{"Science"},
|
||||||
|
})
|
||||||
|
|
||||||
|
code, body := callWrite(setup.Token, itemID)
|
||||||
|
require.Equal(t, http.StatusOK, code, "%s write", tc.ext)
|
||||||
|
assert.Equal(t, "audio-tags", body["metadata_target"], tc.ext)
|
||||||
|
|
||||||
|
// ---- verify the FILE's metadata independently ----
|
||||||
|
probe := func() map[string]string {
|
||||||
|
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
|
||||||
|
"format_tags", "-of", "json", abs).Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
var parsed struct {
|
||||||
|
Format struct {
|
||||||
|
Tags map[string]string `json:"tags"`
|
||||||
|
} `json:"format"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(out, &parsed))
|
||||||
|
return parsed.Format.Tags
|
||||||
|
}
|
||||||
|
tags := probe()
|
||||||
|
assert.Equal(t, "Audio New Title", tags["album"], "%s: the scanner's album rung", tc.ext)
|
||||||
|
assert.Equal(t, "Audio New Title", tags["title"], tc.ext)
|
||||||
|
assert.Equal(t, "Audio Author", tags["artist"], tc.ext)
|
||||||
|
assert.Equal(t, "Narrator Person", tags["composer"], "%s: narrators ride composer", tc.ext)
|
||||||
|
assert.Equal(t, "Audio Series", tags["grouping"], tc.ext)
|
||||||
|
assert.Equal(t, "Science", tags["genre"], tc.ext)
|
||||||
|
assert.Equal(t, "1953-04-16", tags["date"], tc.ext)
|
||||||
|
assert.NotContains(t, tags["album"], "Generated Fixture", "%s: old tag replaced", tc.ext)
|
||||||
|
if tc.wantSubtitle {
|
||||||
|
assert.Equal(t, "Audio Sub", tags["subtitle"], "%s: subtitle round-trips", tc.ext)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, tags["subtitle"], "%s: subtitle must not be written where it can't round-trip", tc.ext)
|
||||||
|
writeMap := body["write"].(map[string]interface{})
|
||||||
|
assert.NotContains(t, writeMap["fields_written"], "subtitle", "%s: honest fields_written", tc.ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still valid, same-length audio after the rewrite.
|
||||||
|
durOut, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
|
||||||
|
"format=duration", "-of", "default=noprint_wrappers=1:nokey=1", abs).Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
dur, err := strconv.ParseFloat(strings.TrimSpace(string(durOut)), 64)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Greater(t, dur, 1.5, "%s: audio survived the rewrite", tc.ext)
|
||||||
|
|
||||||
|
// The inline rescan re-read the tags: the DB title stays
|
||||||
|
// the written one.
|
||||||
|
var title string
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
||||||
|
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
|
||||||
|
assert.Equal(t, "Audio New Title", title, tc.ext)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CBZ gets ComicInfo.xml injected and rescan keeps it", func(t *testing.T) {
|
||||||
|
rel := fmt.Sprintf("test-metadata-write-%d/comic/book.cbz", os.Getpid())
|
||||||
|
abs := filepath.Join(uploadsDir, rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
|
||||||
|
// Minimal archive: one "page" file (a text stub — cover
|
||||||
|
// extraction failing is tolerated by the scanner) and no
|
||||||
|
// ComicInfo.xml yet.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := zip.NewWriter(&buf)
|
||||||
|
page, _ := w.Create("page_0001.jpg")
|
||||||
|
_, _ = page.Write([]byte("not-a-real-jpeg"))
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
require.NoError(t, os.WriteFile(abs, buf.Bytes(), 0o644))
|
||||||
|
|
||||||
|
var itemID string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, 'Generated Comic', $2, 'comic_archive', 'application/zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
|
||||||
|
libID, rel,
|
||||||
|
).Scan(&itemID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
editItem(itemID, map[string]interface{}{
|
||||||
|
"title": "Comic New Title",
|
||||||
|
"series": "Comic Series",
|
||||||
|
"series_number": 4,
|
||||||
|
"authors": []string{"Comic Writer"},
|
||||||
|
"tags": []string{"Superhero"},
|
||||||
|
})
|
||||||
|
|
||||||
|
code, body := callWrite(setup.Token, itemID)
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, "comicinfo", body["metadata_target"])
|
||||||
|
|
||||||
|
// ---- verify the FILE's ComicInfo.xml by parsing it ----
|
||||||
|
reader, err := zip.OpenReader(abs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var infoData []byte
|
||||||
|
for _, f := range reader.File {
|
||||||
|
if f.Name == "ComicInfo.xml" {
|
||||||
|
rc, err := f.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
infoData, err = io.ReadAll(rc)
|
||||||
|
require.NoError(t, rc.Close())
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.Close()
|
||||||
|
require.NotEmpty(t, infoData, "ComicInfo.xml must exist inside the CBZ")
|
||||||
|
|
||||||
|
var info struct {
|
||||||
|
Title string `xml:"Title"`
|
||||||
|
Series string `xml:"Series"`
|
||||||
|
Number int `xml:"Number"`
|
||||||
|
Writer string `xml:"Writer"`
|
||||||
|
}
|
||||||
|
require.NoError(t, xml.Unmarshal(infoData, &info))
|
||||||
|
assert.Equal(t, "Comic New Title", info.Title)
|
||||||
|
assert.Equal(t, "Comic Series", info.Series)
|
||||||
|
assert.Equal(t, 4, info.Number)
|
||||||
|
assert.Equal(t, "Comic Writer", info.Writer)
|
||||||
|
|
||||||
|
// The inline rescan reads the ComicInfo back: the DB title and
|
||||||
|
// series stay the written values.
|
||||||
|
var title, series string
|
||||||
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
||||||
|
`SELECT title, series FROM media_items WHERE id = $1`, itemID).Scan(&title, &series))
|
||||||
|
assert.Equal(t, "Comic New Title", title)
|
||||||
|
assert.Equal(t, "Comic Series", series)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.RemoveAll(filepath.Join(uploadsDir, fmt.Sprintf("test-metadata-write-%d", os.Getpid())))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSearchViewerProgress pins the search-progress batch (2026-10-27):
|
||||||
|
// the unified search row carries the REQUESTING user's reading progress
|
||||||
|
// (viewer_percentage) via a LEFT JOIN on the same user_id param the
|
||||||
|
// visibility join binds, the JSON endpoint serializes it, and the
|
||||||
|
// layout=dropdown HX branch renders templ rows with the completed check
|
||||||
|
// / partial bar. Fixture: three books in a test library; the FRESH
|
||||||
|
// regular user finished one (1.0), is mid-read on another (0.42) and
|
||||||
|
// never opened the third (NULL). The shared admin's search response is
|
||||||
|
// read but never written — their progress stays untouched.
|
||||||
|
func TestSearchViewerProgress(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Search Progress Library", false)
|
||||||
|
viewerID := getRegularUserID(t, setup.DB)
|
||||||
|
|
||||||
|
books := []struct {
|
||||||
|
title string
|
||||||
|
percentage string // "" = no progress row at all
|
||||||
|
}{
|
||||||
|
{title: "Progress Probe Alpha", percentage: "1.0"},
|
||||||
|
{title: "Progress Probe Beta", percentage: "0.42"},
|
||||||
|
{title: "Progress Probe Gamma", percentage: ""},
|
||||||
|
}
|
||||||
|
itemIDs := map[string]string{}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, b := range books {
|
||||||
|
var itemID string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, '2026-02-01 00:00:00+00', '2026-02-01 00:00:00+00', '2026-02-01 00:00:00+00') RETURNING id`,
|
||||||
|
libID, b.title, fmt.Sprintf("/tmp/test-search-progress/%s.epub", b.title),
|
||||||
|
).Scan(&itemID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
itemIDs[b.title] = itemID
|
||||||
|
if b.percentage != "" {
|
||||||
|
_, err = setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
|
||||||
|
VALUES ($1, $2, $3::float, '2026-02-02 00:00:00+00')`,
|
||||||
|
itemID, viewerID, b.percentage,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("JSON rows carry the viewer's own percentage", func(t *testing.T) {
|
||||||
|
rows := searchJSON(t, setup, setup.RegularToken, "Progress Probe", libID)
|
||||||
|
require.Len(t, rows, 3)
|
||||||
|
byTitle := map[string]map[string]interface{}{}
|
||||||
|
for _, row := range rows {
|
||||||
|
byTitle[row["title"].(string)] = row
|
||||||
|
}
|
||||||
|
assert.Equal(t, float64(1), byTitle["Progress Probe Alpha"]["viewer_percentage"])
|
||||||
|
assert.InDelta(t, 0.42, byTitle["Progress Probe Beta"]["viewer_percentage"], 0.0001)
|
||||||
|
require.Nil(t, byTitle["Progress Probe Gamma"]["viewer_percentage"], "never-opened book must be null")
|
||||||
|
|
||||||
|
// Per-user join: the shared admin (no progress on any fixture
|
||||||
|
// book) sees null across the board — the join binds the
|
||||||
|
// REQUESTER, not a global.
|
||||||
|
adminRows := searchJSON(t, setup, setup.Token, "Progress Probe", libID)
|
||||||
|
require.Len(t, adminRows, 3)
|
||||||
|
for _, row := range adminRows {
|
||||||
|
assert.Nil(t, row["viewer_percentage"], "admin must see null for %v", row["title"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dropdown partial renders progress overlays", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET",
|
||||||
|
setup.Server.URL+"/api/media-items/search?layout=dropdown&q=Progress+Probe&library_id="+libID, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Cookie", "token="+setup.RegularToken)
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := string(bodyBytes)
|
||||||
|
|
||||||
|
assert.Contains(t, body, "3 results for", "count label")
|
||||||
|
// Completed book: the check overlay + title (completed ≥ 0.999).
|
||||||
|
assert.Contains(t, body, "Completed", "completed overlay")
|
||||||
|
// Partial book: the bar at 42%.
|
||||||
|
assert.Contains(t, body, "width: 42.0%", "partial progress bar")
|
||||||
|
// Server-rendered rows, one per hit.
|
||||||
|
assert.Equal(t, 3, strings.Count(body, `class="search-result-item`), "one row per result")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchJSON runs a search as the given token and decodes the row array
|
||||||
|
// (library-scoped: the unified search is fuzzy, so an unscoped q would
|
||||||
|
// sweep in live dev rows).
|
||||||
|
func searchJSON(t *testing.T, setup *TestServerSetup, token, query, libraryID string) []map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest("GET",
|
||||||
|
fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(query), libraryID), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var rows []map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
|
||||||
|
return rows
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSeriesSortAndFilter pins the series page's sort/filter contract
|
||||||
|
// (2026-10-27 feature): the /series SSR page and the /api/series JSON
|
||||||
|
// endpoint share one sort registry (services.ResolveSeriesSort) and one
|
||||||
|
// SQL CASE cascade (GetDistinctSeries). The fixture gives three series
|
||||||
|
// DISTINCT answers per sort key so ordering bugs can't hide:
|
||||||
|
//
|
||||||
|
// created author books viewer progress
|
||||||
|
// Probe Zebra 01-03 Zeta Zed 2 1 of 2 read (01-09) → reading
|
||||||
|
// Probe Mango 01-02 Alpha One 3 3 of 3 read (01-10) → finished
|
||||||
|
// Probe Apple 01-01 Midway Two 1 none → unstarted
|
||||||
|
//
|
||||||
|
// All viewer progress belongs to the FRESH regular user
|
||||||
|
// (getRegularUserID) and cascades away with the test library — the
|
||||||
|
// shared admin's reading data is never touched.
|
||||||
|
func TestSeriesSortAndFilter(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Series Filter Library", false)
|
||||||
|
viewerID := getRegularUserID(t, setup.DB)
|
||||||
|
|
||||||
|
type seriesFixture struct {
|
||||||
|
name string
|
||||||
|
author string
|
||||||
|
createdAt string
|
||||||
|
books []struct {
|
||||||
|
title string
|
||||||
|
percentage string
|
||||||
|
lastReadAt string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fixtures := []seriesFixture{
|
||||||
|
{
|
||||||
|
name: "Probe Zebra Series", author: "Zeta Zed", createdAt: "2026-01-03 12:00:00+00",
|
||||||
|
books: []struct {
|
||||||
|
title string
|
||||||
|
percentage string
|
||||||
|
lastReadAt string
|
||||||
|
}{
|
||||||
|
{title: "Zebra Book 1", percentage: "1.0", lastReadAt: "2026-01-09 08:00:00+00"},
|
||||||
|
{title: "Zebra Book 2", percentage: "0.5", lastReadAt: "2026-01-09 08:00:00+00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Probe Mango Series", author: "Alpha One", createdAt: "2026-01-02 12:00:00+00",
|
||||||
|
books: []struct {
|
||||||
|
title string
|
||||||
|
percentage string
|
||||||
|
lastReadAt string
|
||||||
|
}{
|
||||||
|
{title: "Mango Book 1", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
|
||||||
|
{title: "Mango Book 2", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
|
||||||
|
{title: "Mango Book 3", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Probe Apple Series", author: "Midway Two", createdAt: "2026-01-01 12:00:00+00",
|
||||||
|
books: []struct {
|
||||||
|
title string
|
||||||
|
percentage string
|
||||||
|
lastReadAt string
|
||||||
|
}{
|
||||||
|
{title: "Apple Book 1", percentage: "0", lastReadAt: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
stripComments := regexp.MustCompile(`<!--.*?-->`)
|
||||||
|
// Card headings only: the empty-state panels use different h3
|
||||||
|
// classes ("text-xl font-semibold mb-2"), and "No Series Match"
|
||||||
|
// must not read as a rendered series.
|
||||||
|
nameRegex := regexp.MustCompile(`<h3 class="font-semibold text-sm line-clamp-2"[^>]*>([^<]+)</h3>`)
|
||||||
|
|
||||||
|
renderedNames := func(query string) []string {
|
||||||
|
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+query, setup.RegularToken)
|
||||||
|
if code != 200 {
|
||||||
|
t.Logf("series request %q -> %d; body head: %.500s", query, code, body)
|
||||||
|
}
|
||||||
|
require.Equal(t, 200, code)
|
||||||
|
var names []string
|
||||||
|
for _, m := range nameRegex.FindAllStringSubmatch(stripComments.ReplaceAllString(body, ""), -1) {
|
||||||
|
names = append(names, strings.TrimSpace(m[1]))
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, fx := range fixtures {
|
||||||
|
for _, b := range fx.books {
|
||||||
|
var itemID string
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, series, authors, created_at, imported_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $6, $6) RETURNING id`,
|
||||||
|
libID, b.title, fmt.Sprintf("/tmp/test-series-filter/%s.epub", b.title), fx.name, []string{fx.author}, fx.createdAt,
|
||||||
|
).Scan(&itemID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if b.percentage != "0" && b.percentage != "" {
|
||||||
|
_, err = setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
|
||||||
|
VALUES ($1, $2, $3::float, $4::timestamptz)`,
|
||||||
|
itemID, viewerID, b.percentage, b.lastReadAt,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("default sort is recently added", func(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames(""))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("every sort key orders the grid", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
sort string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"sort=recent", []string{"Zebra", "Mango", "Apple"}},
|
||||||
|
{"sort=recent_asc", []string{"Apple", "Mango", "Zebra"}},
|
||||||
|
{"sort=name_asc", []string{"Apple", "Mango", "Zebra"}},
|
||||||
|
{"sort=name_desc", []string{"Zebra", "Mango", "Apple"}},
|
||||||
|
{"sort=author_asc", []string{"Mango", "Apple", "Zebra"}},
|
||||||
|
{"sort=author_desc", []string{"Zebra", "Apple", "Mango"}},
|
||||||
|
{"sort=books_desc", []string{"Mango", "Zebra", "Apple"}},
|
||||||
|
{"sort=books_asc", []string{"Apple", "Zebra", "Mango"}},
|
||||||
|
{"sort=progress_desc", []string{"Mango", "Zebra", "Apple"}},
|
||||||
|
{"sort=progress_asc", []string{"Apple", "Zebra", "Mango"}},
|
||||||
|
{"sort=last_read_desc", []string{"Mango", "Zebra", "Apple"}},
|
||||||
|
{"sort=last_read_asc", []string{"Zebra", "Mango", "Apple"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := renderedNames("&" + tc.sort)
|
||||||
|
require.Len(t, got, 3, tc.sort)
|
||||||
|
for i, want := range tc.want {
|
||||||
|
assert.Contains(t, got[i], want, "%s: position %d", tc.sort, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown sort falls back to the default", func(t *testing.T) {
|
||||||
|
// Unknown keys resolve to "recent" in the registry; the SQL never
|
||||||
|
// interpolates the value (bound-param CASE cascade), so a hostile
|
||||||
|
// sort value is inert by construction.
|
||||||
|
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames("&sort=bogus-key"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("base sort + direction toggle composes registry keys", func(t *testing.T) {
|
||||||
|
// The toolbar sends base keys + dir; defaults apply when dir is
|
||||||
|
// absent, and both directions of every base key are reachable.
|
||||||
|
cases := []struct {
|
||||||
|
query string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"sort=name", []string{"Apple", "Mango", "Zebra"}}, // name asc default
|
||||||
|
{"sort=name&dir=desc", []string{"Zebra", "Mango", "Apple"}}, // toggle flips
|
||||||
|
{"sort=books&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // books desc default, flipped
|
||||||
|
{"sort=progress&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // progress desc default, flipped
|
||||||
|
{"sort=author", []string{"Mango", "Apple", "Zebra"}}, // author asc default
|
||||||
|
// THE toggle regression (2026-10-27): "recent" is both a base
|
||||||
|
// key and a registry key — dir=asc used to be swallowed.
|
||||||
|
{"sort=recent&dir=asc", []string{"Apple", "Mango", "Zebra"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := renderedNames("&" + tc.query)
|
||||||
|
require.Len(t, got, 3, tc.query)
|
||||||
|
for i, want := range tc.want {
|
||||||
|
assert.Contains(t, got[i], want, "%s: position %d", tc.query, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("name query filters", func(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=Mango"))
|
||||||
|
// ILIKE is case-insensitive and substring-shaped.
|
||||||
|
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=mango"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multi-field query matches authors and book titles", func(t *testing.T) {
|
||||||
|
// Author of Mango is "Alpha One" — the query no longer needs the
|
||||||
|
// separate author box.
|
||||||
|
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=alpha+one"))
|
||||||
|
// "Zebra Book 2" is a book TITLE inside the Zebra series: the
|
||||||
|
// series must surface for a title match too.
|
||||||
|
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&q=zebra+book+2"))
|
||||||
|
// Nothing matches anywhere.
|
||||||
|
assert.Empty(t, renderedNames("&q=nobody-nowhere"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("author param (API back-compat) filters on any book's author", func(t *testing.T) {
|
||||||
|
// The toolbar no longer renders a separate author box (the
|
||||||
|
// multi-field q covers authors), but the param stays live for
|
||||||
|
// the JSON API and deep links.
|
||||||
|
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&author=alpha"))
|
||||||
|
assert.Empty(t, renderedNames("&author=nobody"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("status filter matches the viewer's progress", func(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&status=finished"), "all books finished")
|
||||||
|
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&status=reading"), "some book mid-read")
|
||||||
|
assert.Equal(t, []string{"Probe Apple Series"}, renderedNames("&status=unstarted"), "no progress anywhere")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("filters compose", func(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&q=probe&status=reading"))
|
||||||
|
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=probe&status=finished&sort=progress_desc"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cards carry the viewer's read count", func(t *testing.T) {
|
||||||
|
_, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID, setup.RegularToken)
|
||||||
|
assert.Contains(t, body, "3 of 3 read")
|
||||||
|
assert.Contains(t, body, "1 of 2 read")
|
||||||
|
assert.NotContains(t, body, "of 1 read", "unstarted series must not render a progress line")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("JSON API shares the sort/filter contract", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET", setup.Server.URL+"/api/series?library_id="+libID+"&sort=author_asc&status=finished", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Series []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReadCount int64 `json:"read_count"`
|
||||||
|
} `json:"series"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
|
||||||
|
require.Len(t, payload.Series, 1)
|
||||||
|
assert.Equal(t, "Probe Mango Series", payload.Series[0].Name)
|
||||||
|
assert.Equal(t, int64(3), payload.Series[0].ReadCount)
|
||||||
|
assert.Equal(t, 1, payload.Total)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -174,7 +174,7 @@ CREATE TABLE IF NOT EXISTS media_items (
|
|||||||
language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1)
|
language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1)
|
||||||
edition VARCHAR(100), -- Edition information
|
edition VARCHAR(100), -- Edition information
|
||||||
page_count INTEGER, -- Total page count
|
page_count INTEGER, -- Total page count
|
||||||
genre VARCHAR(100), -- Genre classification
|
genre TEXT, -- Genre classification (smart-genre shelf OR raw tag free text — Audible category strings exceed 100 chars)
|
||||||
copyright_year INTEGER, -- Copyright/publication year
|
copyright_year INTEGER, -- Copyright/publication year
|
||||||
goodreads_id VARCHAR(50), -- Goodreads identifier
|
goodreads_id VARCHAR(50), -- Goodreads identifier
|
||||||
openlibrary_id VARCHAR(50), -- Open Library identifier
|
openlibrary_id VARCHAR(50), -- Open Library identifier
|
||||||
@@ -279,6 +279,14 @@ CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING
|
|||||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
|
||||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
||||||
|
|
||||||
|
-- Widen genre for existing databases: the audio tag fallback stores the
|
||||||
|
-- raw file genre atom (Audible category strings exceed 100 chars —
|
||||||
|
-- e.g. "Politicians, Presidents & Heads of State, United States, ..."),
|
||||||
|
-- and VARCHAR(100) rejected the whole import/update at the database
|
||||||
|
-- layer (SQLSTATE 22001). VARCHAR -> TEXT is a metadata-only change,
|
||||||
|
-- safe to re-run (media_highlights position widening precedent).
|
||||||
|
ALTER TABLE media_items ALTER COLUMN genre TYPE TEXT;
|
||||||
|
|
||||||
-- Audiobook editorial facts (A2a): narrators are user-editable first-class
|
-- Audiobook editorial facts (A2a): narrators are user-editable first-class
|
||||||
-- (search/filter/badge surface); abridged rides along; audio_metadata keeps
|
-- (search/filter/badge surface); abridged rides along; audio_metadata keeps
|
||||||
-- the scan-derived tech record (duration/codec/chapters).
|
-- the scan-derived tech record (duration/codec/chapters).
|
||||||
|
|||||||
+20
-2
@@ -37,14 +37,15 @@ These are the response SHAPES (JSON key sets, not values) frozen by the
|
|||||||
| `CollectionsJSON` | `GET /api/collections/:id` (all 4 branches) | envelope `id, user_id, name, description, color, icon, auto_assign_rules, view_settings, books[], book_count, is_smart, created_at`; books = `BookInfo` |
|
| `CollectionsJSON` | `GET /api/collections/:id` (all 4 branches) | envelope `id, user_id, name, description, color, icon, auto_assign_rules, view_settings, books[], book_count, is_smart, created_at`; books = `BookInfo` |
|
||||||
| | `POST /api/collections/preview` | bare `{items: BookInfo[]}` |
|
| | `POST /api/collections/preview` | bare `{items: BookInfo[]}` |
|
||||||
| `SeriesJSON` | `GET /api/series/books?name=&library_id=` | `{name, library_id, books: BookInfo[], total}` |
|
| `SeriesJSON` | `GET /api/series/books?name=&library_id=` | `{name, library_id, books: BookInfo[], total}` |
|
||||||
| `SearchJSON` | `GET /api/media-items/search?q=&library_id=` | top-level array of `SearchMediaItemsUnifiedRow` (47 keys) |
|
| `SearchJSON` | `GET /api/media-items/search?q=&library_id=` | top-level array of `SearchMediaItemsUnifiedRow` (47 keys + viewer_percentage) |
|
||||||
| `DashboardSections` | `GET /api/dashboard/sections` | `{sections[]: {id, collection_id, is_system, title, description, icon, items: BookInfo[], view_all_url, priority}}` |
|
| `DashboardSections` | `GET /api/dashboard/sections` | `{sections[]: {id, collection_id, is_system, title, description, icon, items: BookInfo[], view_all_url, priority}}` |
|
||||||
| `MediaListBothBranches` | `GET /api/media-items` (unscoped) | `{data: [38-key resolved map]}` (intentionally divergent from the scoped branch until the row-shape collapse batch) |
|
| `MediaListBothBranches` | `GET /api/media-items` (unscoped) | `{data: [38-key resolved map]}` (intentionally divergent from the scoped branch until the row-shape collapse batch) |
|
||||||
| | `GET /api/media-items?library_id=&sort=` | `{data: [raw ListMediaItemsSortedRow, 70 sqlc keys]}` |
|
| | `GET /api/media-items?library_id=&sort=` | `{data: [raw ListMediaItemsSortedRow, 70 sqlc keys]}` |
|
||||||
| `SSRPages` | `/bookshelf`, `/collections`, `/collections/:id`, `/series`, `/series/detail`, `/tags/detail` | 200 + stable title/substring markers |
|
| `SSRPages` | `/bookshelf`, `/collections`, `/collections/:id`, `/series`, `/series/detail`, `/tags/detail` | 200 + stable title/substring markers |
|
||||||
|
|
||||||
`BookInfo` (every card surface): `media_item_id, title, author,
|
`BookInfo` (every card surface): `media_item_id, title, author,
|
||||||
cover_image_path, has_conflict, format_group, badge`.
|
cover_image_path, has_conflict, format_group, badge, viewer_percentage,
|
||||||
|
library_name`.
|
||||||
|
|
||||||
## Changelog (additive keys per batch)
|
## Changelog (additive keys per batch)
|
||||||
|
|
||||||
@@ -414,3 +415,20 @@ cover_image_path, has_conflict, format_group, badge`.
|
|||||||
BEFORE Rescan All — force rescan stamps books with the current
|
BEFORE Rescan All — force rescan stamps books with the current
|
||||||
setting, so bump-first + force = one pass of work; force-first +
|
setting, so bump-first + force = one pass of work; force-first +
|
||||||
bump = the trickle re-derives everything again (wasted, not harmful).
|
bump = the trickle re-derives everything again (wasted, not harmful).
|
||||||
|
|
||||||
|
## 2026-10-27 — Search-progress batch: viewer progress on cards
|
||||||
|
- **`SearchJSON`**: `SearchMediaItemsUnifiedRow` + `viewer_percentage`
|
||||||
|
(float 0..1, NULL when the requester never opened the book). The
|
||||||
|
query LEFT JOINs `reading_progress` on the SAME `user_id` param the
|
||||||
|
library-visibility join already binds — one requester param, no new
|
||||||
|
wire inputs; the join is row-neutral (UNIQUE(media_item, user)).
|
||||||
|
- **`BookInfo`** (CollectionsJSON / SeriesJSON / preview /
|
||||||
|
DashboardSections / search cards): + `viewer_percentage` (JSON null
|
||||||
|
when nil) and + `library_name` (empty on feeds that don't load it —
|
||||||
|
only the search-unified adapter populates both today). Adapters
|
||||||
|
other than BookCardsFromUnified keep emitting null/""; templ cards
|
||||||
|
render the progress bar/completed check only when populated.
|
||||||
|
- The header-search dropdown now renders the templ `SearchResults`
|
||||||
|
partial (layout=dropdown on the same endpoint, HX-Request) instead
|
||||||
|
of the retired innerHTML renderer in web/src/search.ts — same
|
||||||
|
endpoint, same auth, content server-rendered.
|
||||||
@@ -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
|
# Scan Library
|
||||||
|
|
||||||
Enqueues a full scan of one library's configured folders. The library id comes
|
Enqueues a scan of one library's configured folders. The library id comes
|
||||||
from the path — no request body is needed (the generic `POST /api/scanner/scan`
|
from the path — no request body is needed (the generic `POST /api/scanner/scan`
|
||||||
variant is the one that takes `folder_paths`).
|
variant is the one that takes `folder_paths`).
|
||||||
|
|
||||||
|
**Per-folder scope**: an optional body of `{"folder_path": "…"}` restricts the
|
||||||
|
scan to ONE of the library's registered folders (the per-folder Scan buttons
|
||||||
|
in the library panel). The path must be a registered folder — anything else is
|
||||||
|
a **400**. A scoped scan never runs archive reconciliation for the library's
|
||||||
|
other folders, so items outside the scanned folder cannot be marked missing
|
||||||
|
by it.
|
||||||
|
|
||||||
Scans are **coalesced**: if a scan for this library is already pending or
|
Scans are **coalesced**: if a scan for this library is already pending or
|
||||||
running you get **409** with the active job id instead of a second scan.
|
running you get **409** with the active job id instead of a second scan.
|
||||||
|
|
||||||
@@ -29,7 +36,7 @@ running you get **409** with the active job id instead of a second scan.
|
|||||||
|
|
||||||
| Status | Body | When |
|
| Status | Body | When |
|
||||||
| ------ | ---- | ---- |
|
| ------ | ---- | ---- |
|
||||||
| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` | malformed id / empty library |
|
| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` / `{"error": "folder_path is not one of this library's registered folders"}` | malformed id / empty library / unregistered folder scope |
|
||||||
| 404 | `{"error": "library not found or has no folders"}` | unknown library |
|
| 404 | `{"error": "library not found or has no folders"}` | unknown library |
|
||||||
| 409 | `{"error": "a scan for this library is already pending or running", "active_job_id": "..."}` | scan coalescing |
|
| 409 | `{"error": "a scan for this library is already pending or running", "active_job_id": "..."}` | scan coalescing |
|
||||||
| 500 | `{"error": "failed to enqueue scan job: ..."}` | queue failure |
|
| 500 | `{"error": "failed to enqueue scan job: ..."}` | queue failure |
|
||||||
@@ -35,7 +35,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|||||||
"username": "john",
|
"username": "john",
|
||||||
"first_name": "John",
|
"first_name": "John",
|
||||||
"last_name": "Doe",
|
"last_name": "Doe",
|
||||||
"role": "user"
|
"role": "user",
|
||||||
|
"keep_screen_awake": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Bookhoard API
|
title: Bookhoard API
|
||||||
version: '2026.10.24'
|
version: '2026.10.27'
|
||||||
description: |
|
description: |
|
||||||
Generated from the canonical API docs (docs/developer/api/**) by
|
Generated from the canonical API docs (docs/developer/api/**) by
|
||||||
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
|
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
|
||||||
@@ -1141,6 +1141,15 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
description: Success
|
||||||
|
"/api/libraries/{id}/issues/{issueId}/retry":
|
||||||
|
post:
|
||||||
|
tags: [libraries]
|
||||||
|
summary: "Retry Processing Issue"
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Success
|
||||||
"/api/libraries/{id}/issues/{issueId}/{mediaItemId}/resolve":
|
"/api/libraries/{id}/issues/{issueId}/{mediaItemId}/resolve":
|
||||||
post:
|
post:
|
||||||
tags: [libraries]
|
tags: [libraries]
|
||||||
|
|||||||
@@ -298,6 +298,35 @@ A: Calibre Web uses the same `metadata.opf` files, so yes - Bookhoard can scan a
|
|||||||
|
|
||||||
A: Bookhoard has its own sorting logic and doesn't import Calibre sort fields. This allows for consistent sorting across all books.
|
A: Bookhoard has its own sorting logic and doesn't import Calibre sort fields. This allows for consistent sorting across all books.
|
||||||
|
|
||||||
|
## Writing metadata back to files (admin)
|
||||||
|
|
||||||
|
The book page's metadata editor has a **Write to file** action alongside
|
||||||
|
Save. Save updates Bookhoard's database only; Write to file persists the
|
||||||
|
currently saved values into the artifact on disk, then re-extracts so
|
||||||
|
everything stays in sync:
|
||||||
|
|
||||||
|
- **EPUB/KEPUB** — the OPF embedded in the book is rewritten in place
|
||||||
|
(cover, chapters and reading position are untouched). The file's
|
||||||
|
checksum changes, so device syncs (Kobo, KOReader) re-key the book on
|
||||||
|
their next sync.
|
||||||
|
- **Single-file audio (MP3/FLAC/M4A/M4B)** — the container's own tags
|
||||||
|
are rewritten (audio streams copied untouched, chapters and cover art
|
||||||
|
preserved). Only fields the format can carry are written: title,
|
||||||
|
subtitle (MP3/FLAC), authors, narrators (composer tag — Audible's
|
||||||
|
convention), series, date and genre.
|
||||||
|
- **CBZ** — a ComicInfo.xml is written inside the archive (injected or
|
||||||
|
replaced). CBR/CB7 archives can't be rewritten in place, so they take
|
||||||
|
the sidecar path.
|
||||||
|
- **Everything else** (PDF, folder books, CBR/CB7) — a Calibre-style
|
||||||
|
sidecar is written next to the file:
|
||||||
|
`metadata.opf` when the book is alone in its folder (Calibre's own
|
||||||
|
convention, so Calibre reads it too), or `<filename>.opf` when several
|
||||||
|
books share a folder, so one book's sidecar can never leak onto its
|
||||||
|
neighbours.
|
||||||
|
|
||||||
|
The write mirrors exactly what the scanner reads back, and the original
|
||||||
|
file is never touched if anything fails (temp file + atomic rename).
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
- [Calibre Website](https://calibre-ebook.com/)
|
- [Calibre Website](https://calibre-ebook.com/)
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const BASE = "http://localhost:8765";
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const EMAIL = `e2e-search-${STAMP}@example.com`;
|
||||||
|
const USERNAME = `e2e-search-${STAMP}`;
|
||||||
|
const PASSWORD = "E2e-Playwright-2026!";
|
||||||
|
|
||||||
|
let token = "";
|
||||||
|
|
||||||
|
const consoleFailures: string[] = [];
|
||||||
|
|
||||||
|
function watchPage(page: Page): void {
|
||||||
|
page.on("pageerror", (err) => {
|
||||||
|
consoleFailures.push(`pageerror: ${err.message}`);
|
||||||
|
});
|
||||||
|
page.on("console", (msg) => {
|
||||||
|
if (msg.type() === "error") {
|
||||||
|
consoleFailures.push(`console.error: ${msg.text()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectNoClientErrors(): void {
|
||||||
|
const bad = consoleFailures.filter((f) =>
|
||||||
|
/(is not defined|is not a function|headerSearch|alpine)/i.test(f),
|
||||||
|
);
|
||||||
|
expect(bad, `uncaught client errors:\n${bad.join("\n")}`).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiLogin(request: APIRequestContext): Promise<string> {
|
||||||
|
const res = await request.post("/api/auth/login", {
|
||||||
|
data: { login: EMAIL, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(res.status(), "login should succeed").toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
return body.access_token as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test.describe("Header search + audio scrubber (client wiring)", () => {
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser, request }) => {
|
||||||
|
const reg = await request.post("/api/auth/register", {
|
||||||
|
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect([201, 200, 409]).toContain(reg.status());
|
||||||
|
token = await apiLogin(request);
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
|
||||||
|
page = await browser.newPage();
|
||||||
|
watchPage(page);
|
||||||
|
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
expectNoClientErrors();
|
||||||
|
await page?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("header search dropdown opens on typing (regression: tree-shaken module)", async () => {
|
||||||
|
// Drive the header on TWO pages: the bundle is shared, but this
|
||||||
|
// catches any page-scoped wiring surprise.
|
||||||
|
for (const pagePath of ["/bookshelf", "/series"]) {
|
||||||
|
await page.goto(pagePath);
|
||||||
|
const input = page.locator("#header-search");
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
|
||||||
|
// The regression this pins: search.ts (which registers the
|
||||||
|
// headerSearch Alpine component) was tree-shaken from the bundle,
|
||||||
|
// so typing did nothing — no request, no dropdown. Type with REAL
|
||||||
|
// key events (not fill) and assert the request carries the query.
|
||||||
|
await input.click();
|
||||||
|
await input.pressSequentially("algo", { delay: 90 });
|
||||||
|
|
||||||
|
const requestPromise = page.waitForRequest(
|
||||||
|
(req) =>
|
||||||
|
req.url().includes("/api/media-items/search") &&
|
||||||
|
req.url().includes("q=algo") &&
|
||||||
|
(req.headers()["hx-request"] === "true" || req.headers()["hx-request"] === "true"),
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
|
const req = await requestPromise;
|
||||||
|
expect(req.url()).toContain("layout=dropdown");
|
||||||
|
|
||||||
|
const results = page.locator("#search-results");
|
||||||
|
await expect(results).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(results.locator(".search-result-item").first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape closes the dropdown (keyboard contract).
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(results).toBeHidden();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("audio scrubber: grabbing the handle does not teleport (plain and Shift)", async ({ request }) => {
|
||||||
|
// Find an audiobook in the shared dev library (plain media list,
|
||||||
|
// then filter client-side — the search endpoint's facet behavior
|
||||||
|
// with empty q isn't a listing API).
|
||||||
|
const res = await request.get("/api/media-items", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { data?: Array<{ id: string; format_group?: string }> };
|
||||||
|
const rows = (body.data ?? []).filter((r) => r.format_group === "audiobook");
|
||||||
|
test.skip(rows.length === 0, "no audiobooks in the dev library");
|
||||||
|
const bookId = rows[0].id;
|
||||||
|
|
||||||
|
await page.goto(`/readers/${bookId}`);
|
||||||
|
const scrubArea = page.locator("#scrub-area");
|
||||||
|
await expect(scrubArea).toBeVisible();
|
||||||
|
|
||||||
|
// The fresh user has never played this book — park the playhead at
|
||||||
|
// 30% via the progress API so the grab tests run mid-book.
|
||||||
|
const put = await request.put(`/api/media-items/${bookId}/progress`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { percentage: 0.3, device_id: "e2e-scrubber" },
|
||||||
|
});
|
||||||
|
expect([200, 204]).toContain(put.status());
|
||||||
|
await page.reload();
|
||||||
|
await expect(scrubArea).toBeVisible();
|
||||||
|
|
||||||
|
const box = await scrubArea.boundingBox();
|
||||||
|
test.skip(!box, "scrub bar not laid out");
|
||||||
|
const y = box!.y + box!.height / 2;
|
||||||
|
|
||||||
|
// Where is the thumb right now? The native range input mirrors the
|
||||||
|
// playhead; read its aria-valuenow + max to compute the thumb X.
|
||||||
|
const thumbX = await page.evaluate(() => {
|
||||||
|
const input = document.getElementById("audio-scrub") as HTMLInputElement;
|
||||||
|
const area = document.getElementById("scrub-area")!;
|
||||||
|
const rect = area.getBoundingClientRect();
|
||||||
|
const value = Number(input.value || 0);
|
||||||
|
const max = Number(input.max || 1);
|
||||||
|
return rect.left + (value / max) * rect.width;
|
||||||
|
});
|
||||||
|
|
||||||
|
const readScrubValue = () =>
|
||||||
|
page.evaluate(() => Number((document.getElementById("audio-scrub") as HTMLInputElement).value));
|
||||||
|
|
||||||
|
const before = await readScrubValue();
|
||||||
|
expect(before).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// --- plain grab: pointerdown ON the thumb must not move it ---
|
||||||
|
await page.mouse.move(thumbX, y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
const atGrab = await readScrubValue();
|
||||||
|
expect(Math.abs(atGrab - before)).toBeLessThan(
|
||||||
|
Math.max(1, before * 0.0005),
|
||||||
|
"grabbing the handle must not jump the playhead",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drag left by 60px: the handle follows RELATIVELY.
|
||||||
|
await page.mouse.move(thumbX - 60, y, { steps: 5 });
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
const afterDrag = await readScrubValue();
|
||||||
|
expect(afterDrag).toBeLessThan(atGrab, "dragging left scrubs backward");
|
||||||
|
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(400); // seek commits
|
||||||
|
|
||||||
|
// --- Shift+grab: the precision rail appears, no teleport ---
|
||||||
|
const railBefore = await readScrubValue();
|
||||||
|
// Re-locate the thumb after the commit.
|
||||||
|
const thumbX2 = await page.evaluate(() => {
|
||||||
|
const input = document.getElementById("audio-scrub") as HTMLInputElement;
|
||||||
|
const area = document.getElementById("scrub-area")!;
|
||||||
|
const rect = area.getBoundingClientRect();
|
||||||
|
const value = Number(input.value || 0);
|
||||||
|
const max = Number(input.max || 1);
|
||||||
|
return rect.left + (value / max) * rect.width;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.keyboard.down("Shift");
|
||||||
|
await page.mouse.move(thumbX2, y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
const railAtGrab = await readScrubValue();
|
||||||
|
expect(Math.abs(railAtGrab - railBefore)).toBeLessThan(
|
||||||
|
Math.max(1, railBefore * 0.0005),
|
||||||
|
"Shift+grab must not teleport either",
|
||||||
|
);
|
||||||
|
// The rail is the feedback for Shift+grab — it must be visible.
|
||||||
|
await expect(page.locator('div[x-show="railVisible"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.up("Shift");
|
||||||
|
await page.mouse.up();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
|
const BASE = "http://localhost:8765";
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const EMAIL = `e2e-setfb-${STAMP}@example.com`;
|
||||||
|
const USERNAME = `e2e-setfb-${STAMP}`;
|
||||||
|
const PASSWORD = "E2e-Playwright-2026!";
|
||||||
|
|
||||||
|
// Settings-page save feedback: every save surface must acknowledge the
|
||||||
|
// click (toast) and the scan form must NOT swap the handler's JSON body
|
||||||
|
// over its card (the pre-toast behavior — raw JSON replaced the form).
|
||||||
|
//
|
||||||
|
// Dev-state safety: every success save re-submits the value ALREADY in
|
||||||
|
// the input (read from the page first), so nothing in the shared dev DB
|
||||||
|
// changes; the error-path request is rejected server-side before any
|
||||||
|
// write.
|
||||||
|
|
||||||
|
let token = "";
|
||||||
|
let page = null as Page | null;
|
||||||
|
// system_config.updated_by audit restore: the config save handler stamps
|
||||||
|
// the requesting admin's id onto system_config rows. The harness cleanup
|
||||||
|
// deletes transient example.com users, and this FK (no cascade) would
|
||||||
|
// block the delete and fail unrelated integration tests. Capture before
|
||||||
|
// any save, restore after.
|
||||||
|
let e2eUserID = "";
|
||||||
|
let origUpdatedBy = "";
|
||||||
|
|
||||||
|
function psql(sql: string): string {
|
||||||
|
const flat = sql.replace(/\s+/g, " ");
|
||||||
|
return execSync(
|
||||||
|
`docker exec bookhoard_db psql -U postgres -d bookhoard -tAc ${JSON.stringify(flat)}`,
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiLogin(request: APIRequestContext): Promise<string> {
|
||||||
|
const res = await request.post("/api/auth/login", {
|
||||||
|
data: { login: EMAIL, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(res.status(), "login should succeed").toBe(200);
|
||||||
|
return (await res.json()).access_token as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test.describe("Settings save feedback (toasts + disabled buttons)", () => {
|
||||||
|
test.beforeAll(async ({ browser, request }) => {
|
||||||
|
// Fresh user, promoted to admin in the dev DB (the harness cleanup
|
||||||
|
// sweeps transient example.com users afterwards).
|
||||||
|
const reg = await request.post("/api/auth/register", {
|
||||||
|
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect([201, 200, 409]).toContain(reg.status());
|
||||||
|
psql(`UPDATE users SET role='admin' WHERE email='${EMAIL}'`);
|
||||||
|
token = await apiLogin(request);
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
|
||||||
|
e2eUserID = psql(`SELECT id FROM users WHERE email='${EMAIL}'`);
|
||||||
|
origUpdatedBy = psql(`SELECT COALESCE(updated_by::text, '') FROM system_config WHERE key='base_url'`);
|
||||||
|
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
|
||||||
|
await page.goto("/admin/settings");
|
||||||
|
await expect(page.locator("h1")).toHaveText("System Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await page?.close();
|
||||||
|
// Hand system_config.updated_by back to whoever held it (or NULL),
|
||||||
|
// so the transient user can be swept.
|
||||||
|
if (e2eUserID) {
|
||||||
|
const restore = origUpdatedBy
|
||||||
|
? `'${origUpdatedBy}'::uuid`
|
||||||
|
: "NULL";
|
||||||
|
psql(`UPDATE system_config SET updated_by = ${restore} WHERE updated_by = '${e2eUserID}'::uuid`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Base URL save shows a success toast — twice (twin-form drift pin)", async () => {
|
||||||
|
const input = page.locator("#settings-form input[name='base_url']");
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
|
||||||
|
// First save goes through the templ form…
|
||||||
|
await page.locator("#settings-form button[type='submit']").first().click();
|
||||||
|
await expect(page.getByText("Settings saved", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// …the second through the Go-source twin the handler swaps in after
|
||||||
|
// the first save. If the twin ever drops data-settings-save (the
|
||||||
|
// handlers→templates import cycle is why the twin exists), THIS is
|
||||||
|
// the assertion that fails.
|
||||||
|
await page.waitForTimeout(300); // let the swap settle
|
||||||
|
await expect(page.locator("#settings-form")).toBeVisible();
|
||||||
|
await page.locator("#settings-form button[type='submit']").first().click();
|
||||||
|
await expect(page.getByText("Settings saved", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Scan settings save shows a toast and never swaps raw JSON over the card", async () => {
|
||||||
|
const section = page.locator("#scan-settings-section");
|
||||||
|
await expect(section).toBeVisible();
|
||||||
|
|
||||||
|
await section.locator("button[type='submit']").click();
|
||||||
|
await expect(page.getByText("Scan settings saved", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The regression: the scan handler responds JSON; with the old
|
||||||
|
// hx-swap="outerHTML" the card was replaced by the literal JSON text.
|
||||||
|
await expect(section).toBeVisible();
|
||||||
|
await expect(section.locator("form")).toBeVisible();
|
||||||
|
const body = await page.locator("body").innerText();
|
||||||
|
expect(body).not.toContain("scan settings updated successfully");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Tunable row save shows a toast and the per-row status span", async () => {
|
||||||
|
const row = page.locator("form[data-settings-save='tunable']").first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
const key = await row.locator("input[name='key']").getAttribute("value");
|
||||||
|
expect(key).toBeTruthy();
|
||||||
|
|
||||||
|
await row.locator("button[type='submit']").click();
|
||||||
|
await expect(page.getByText("Setting saved", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
// Server-driven per-row status (kept alongside the toast; it carries
|
||||||
|
// the "restart required" nuance).
|
||||||
|
await expect(page.locator(`#status-${key}`)).toContainText("Saved", {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Invalid value shows an error toast and the form survives", async () => {
|
||||||
|
// Inject a genuinely invalid option and select it: setting a select
|
||||||
|
// to a value with no matching option silently serializes as NO field
|
||||||
|
// at all (no selected option → nothing submitted), which would save
|
||||||
|
// fine instead of failing. With a real option present, the server
|
||||||
|
// rejects with 400 {"error":"invalid timezone"} before any write.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const select = document.getElementById("default_timezone") as HTMLSelectElement;
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "Not/AZone";
|
||||||
|
opt.textContent = "E2E invalid";
|
||||||
|
select.appendChild(opt);
|
||||||
|
select.value = "Not/AZone";
|
||||||
|
});
|
||||||
|
await page.locator("#settings-form button[type='submit']").first().click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Save failed: invalid timezone")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
// htmx does not swap non-2xx bodies: the form is intact.
|
||||||
|
await expect(page.locator("#settings-form")).toBeVisible();
|
||||||
|
await expect(page.locator("#settings-form input[name='base_url']")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const REPO = process.cwd(); // uploads/ here is bind-mounted at /app/uploads
|
||||||
|
const BASE = "http://localhost:8765";
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const EMAIL = `e2e-meta-${STAMP}@example.com`;
|
||||||
|
const USERNAME = `e2e-meta-${STAMP}`;
|
||||||
|
const PASSWORD = "E2e-Playwright-2026!";
|
||||||
|
|
||||||
|
// Host-side upload dir (bind-mounted into the app container).
|
||||||
|
const UPLOADS_HOST = path.join(REPO, "uploads", `e2e-meta-${STAMP}`);
|
||||||
|
const UPLOADS_CONT = `/app/uploads/e2e-meta-${STAMP}`;
|
||||||
|
|
||||||
|
interface FormatSeed {
|
||||||
|
ext: string;
|
||||||
|
group: string;
|
||||||
|
mime: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every write-capable file type, through the REAL admin UI, verified by
|
||||||
|
// reading each artifact back independently afterwards (this test IS the
|
||||||
|
// "choose each file type, then check the file's metadata yourself"
|
||||||
|
// contract):
|
||||||
|
//
|
||||||
|
// epub → embedded OPF splice; verify by parsing the OPF XML
|
||||||
|
// pdf → Calibre metadata.opf sidecar (lone book); parse XML
|
||||||
|
// cbz → ComicInfo.xml injected; parse XML
|
||||||
|
// mp3/flac/m4a/m4b → ffprobe the rewritten tags
|
||||||
|
const FORMATS: FormatSeed[] = [
|
||||||
|
{ ext: "epub", group: "ebook", mime: "application/epub+zip" },
|
||||||
|
{ ext: "pdf", group: "ebook", mime: "application/pdf" },
|
||||||
|
{ ext: "cbz", group: "comic_archive", mime: "application/zip" },
|
||||||
|
{ ext: "mp3", group: "audiobook", mime: "audio/mpeg" },
|
||||||
|
{ ext: "flac", group: "audiobook", mime: "audio/flac" },
|
||||||
|
{ ext: "m4a", group: "audiobook", mime: "audio/mp4" },
|
||||||
|
{ ext: "m4b", group: "audiobook", mime: "audio/mp4" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let token = "";
|
||||||
|
let libraryID = "";
|
||||||
|
|
||||||
|
function psql(sql: string): string {
|
||||||
|
// Collapse whitespace (JSON.stringify would bind newlines as literal
|
||||||
|
// "\n" through the shell and break the SQL).
|
||||||
|
const flat = sql.replace(/\s+/g, " ");
|
||||||
|
return execSync(
|
||||||
|
`docker exec bookhoard_db psql -U postgres -d bookhoard -tAc ${JSON.stringify(flat)}`,
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cmd: string): string {
|
||||||
|
return execSync(cmd, { encoding: "utf8" }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePython(script: string): void {
|
||||||
|
run(`python3 - <<'PYEOF'\n${script}\nPYEOF`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEPUB(dest: string, title: string): void {
|
||||||
|
const script = `import zipfile, shutil
|
||||||
|
arc = ${JSON.stringify(dest)}
|
||||||
|
tmp = arc + ".tmp"
|
||||||
|
with zipfile.ZipFile(tmp, "w") as z:
|
||||||
|
z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip", compress_type=zipfile.ZIP_STORED)
|
||||||
|
z.writestr("META-INF/container.xml", '<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>')
|
||||||
|
z.writestr("OEBPS/content.opf", '<?xml version="1.0"?><package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title id="t1">' + ${JSON.stringify(title)} + '</dc:title><dc:identifier id="pub-id">urn:uuid:e2e</dc:identifier></metadata><manifest><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="c1"/></spine></package>')
|
||||||
|
z.writestr("OEBPS/c1.xhtml", "<html><body>chapter</body></html>")
|
||||||
|
shutil.move(tmp, arc)`;
|
||||||
|
writePython(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCBZ(dest: string): void {
|
||||||
|
writePython(`import zipfile, shutil
|
||||||
|
arc = ${JSON.stringify(dest)}
|
||||||
|
tmp = arc + ".tmp"
|
||||||
|
with zipfile.ZipFile(tmp, "w") as z:
|
||||||
|
z.writestr("page_0001.jpg", "not-a-real-jpeg-but-fine")
|
||||||
|
shutil.move(tmp, arc)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Independent artifact readers — no app code involved. The python is
|
||||||
|
* written to a temp file (never -c through the shell: paths with
|
||||||
|
* quotes/newlines are unreadable that way). */
|
||||||
|
function pythonRead(script: string, absPath: string): string {
|
||||||
|
const scriptPath = path.join(os.tmpdir(), `e2e-meta-read-${STAMP}.py`);
|
||||||
|
fs.writeFileSync(scriptPath, script);
|
||||||
|
return run(`python3 ${JSON.stringify(scriptPath)} ${JSON.stringify(absPath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactTitle(ext: string, absPath: string): string {
|
||||||
|
switch (ext) {
|
||||||
|
case "epub":
|
||||||
|
return pythonRead(`import zipfile, sys, xml.etree.ElementTree as ET
|
||||||
|
with zipfile.ZipFile(sys.argv[1]) as z:
|
||||||
|
root = ET.fromstring(z.read('OEBPS/content.opf'))
|
||||||
|
ns = {'dc': 'http://purl.org/dc/elements/1.1/'}
|
||||||
|
titles = [t.text or '' for t in root.findall('.//dc:title', ns)]
|
||||||
|
print(titles[0] if titles else '')`, absPath);
|
||||||
|
case "cbz":
|
||||||
|
return pythonRead(`import zipfile, sys, xml.etree.ElementTree as ET
|
||||||
|
with zipfile.ZipFile(sys.argv[1]) as z:
|
||||||
|
root = ET.fromstring(z.read('ComicInfo.xml'))
|
||||||
|
print(root.findtext('Title') or '')`, absPath);
|
||||||
|
case "pdf":
|
||||||
|
// one-book dir → the Calibre-style sidecar carries the metadata
|
||||||
|
return pythonRead(`import os, sys, xml.etree.ElementTree as ET
|
||||||
|
sidecar = os.path.join(os.path.dirname(sys.argv[1]), 'metadata.opf')
|
||||||
|
root = ET.parse(sidecar).getroot()
|
||||||
|
ns = {'dc': 'http://purl.org/dc/elements/1.1/'}
|
||||||
|
print(root.findtext('.//dc:title', default='', namespaces=ns))`, absPath);
|
||||||
|
default:
|
||||||
|
// audio containers: ffprobe the rewritten album tag
|
||||||
|
return run(
|
||||||
|
`ffprobe -v error -show_entries format_tags=album -of default=noprint_wrappers=1:nokey=1 ${JSON.stringify(absPath)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiLogin(request: APIRequestContext): Promise<string> {
|
||||||
|
const res = await request.post("/api/auth/login", {
|
||||||
|
data: { login: EMAIL, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(res.status(), "login should succeed").toBe(200);
|
||||||
|
return (await res.json()).access_token as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test.describe("Write-to-file per format (real admin UI + artifact verification)", () => {
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser, request }) => {
|
||||||
|
fs.mkdirSync(UPLOADS_HOST, { recursive: true });
|
||||||
|
|
||||||
|
// Fresh user, promoted to admin in the dev DB (the harness cleanup
|
||||||
|
// sweeps transient example.com users afterwards).
|
||||||
|
const reg = await request.post("/api/auth/register", {
|
||||||
|
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect([201, 200, 409]).toContain(reg.status());
|
||||||
|
|
||||||
|
psql(`UPDATE users SET role='admin' WHERE email='${EMAIL}'`);
|
||||||
|
token = await apiLogin(request);
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
|
||||||
|
// Test library with a folder at our upload dir.
|
||||||
|
const libRes = await request.post("/api/libraries", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { name: `E2E Meta Write ${STAMP}`, type: "ebooks" },
|
||||||
|
});
|
||||||
|
expect(libRes.status()).toBe(201);
|
||||||
|
libraryID = ((await libRes.json()) as { id: string }).id;
|
||||||
|
const foldRes = await request.post(`/api/libraries/${libraryID}/folders`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { folder_path: UPLOADS_CONT },
|
||||||
|
});
|
||||||
|
expect(foldRes.status()).toBe(201);
|
||||||
|
|
||||||
|
// Seed one fixture per format: file on disk + media_items row.
|
||||||
|
for (const f of FORMATS) {
|
||||||
|
const rel = `${f.group}/${f.ext}/e2e-fixture.${f.ext}`;
|
||||||
|
const abs = path.join(UPLOADS_HOST, rel);
|
||||||
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||||
|
if (f.ext === "epub") writeEPUB(abs, "E2E Original Title");
|
||||||
|
else if (f.ext === "cbz") writeCBZ(abs);
|
||||||
|
else if (f.ext === "pdf") fs.writeFileSync(abs, "%PDF-1.4 e2e stub\n");
|
||||||
|
else {
|
||||||
|
const codec = f.ext === "mp3" ? "libmp3lame" : f.ext === "flac" ? "flac" : "aac";
|
||||||
|
run(
|
||||||
|
`ffmpeg -hide_banner -loglevel error -y -f lavfi -i anullsrc=r=44100:cl=mono -t 2 -c:a ${codec} -metadata title='E2E Original Title' -metadata album='E2E Original Title' ${JSON.stringify(abs)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
f.id = psql(
|
||||||
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
||||||
|
VALUES ('${libraryID}', 'E2E Original Title', '${rel}', '${f.group}', '${f.mime}', 100, NOW(), NOW(), NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
);
|
||||||
|
// -tAc prints the id row first, then psql's command tag
|
||||||
|
// ("INSERT 0 1") last; take the first non-empty line.
|
||||||
|
f.id = f.id.split("\n").find((l) => l.trim())!.trim();
|
||||||
|
expect(f.id).toMatch(/^[0-9a-f-]{36}$/i);
|
||||||
|
}
|
||||||
|
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await page?.close();
|
||||||
|
// Best-effort cleanup: the library delete cascades the items; the
|
||||||
|
// psql fallback + rm sweep anything the API couldn't.
|
||||||
|
try {
|
||||||
|
await fetch(`${BASE}/api/libraries/${libraryID}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
fs.rmSync(UPLOADS_HOST, { recursive: true, force: true });
|
||||||
|
try {
|
||||||
|
psql(`DELETE FROM libraries WHERE id='${libraryID}'`);
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const f of FORMATS) {
|
||||||
|
test(`writes metadata into ${f.ext} and the file content verifies`, async () => {
|
||||||
|
const newTitle = `E2E WRITTEN ${f.ext.toUpperCase()}`;
|
||||||
|
const abs = path.join(UPLOADS_HOST, f.group, f.ext, `e2e-fixture.${f.ext}`);
|
||||||
|
expect(f.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Real admin UI flow: book page → Edit → change title → the
|
||||||
|
// in-app dialog → Save & write.
|
||||||
|
await page.goto(`/media/${f.id}`);
|
||||||
|
await page.click('button:has-text("Edit")');
|
||||||
|
const modal = page.locator("#metadata-editor-modal");
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
await page.fill('#metadata-editor-modal input[name="title"]', newTitle);
|
||||||
|
|
||||||
|
await page.click('#metadata-editor-modal button:has-text("Write to file")');
|
||||||
|
const dialog = page.locator("#write-confirm-dialog");
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.locator('button:has-text("Save & write")').click();
|
||||||
|
|
||||||
|
// Wait for the request + inline rescan + page reload to settle.
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
|
||||||
|
// Then verify the FILE's metadata — independently of the app
|
||||||
|
// (read the artifact back with python/ffprobe, not via the app).
|
||||||
|
let seen = "";
|
||||||
|
let lastErr = "";
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
try {
|
||||||
|
seen = artifactTitle(f.ext, abs);
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = String(e);
|
||||||
|
seen = "";
|
||||||
|
}
|
||||||
|
if (seen.includes(newTitle)) break;
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
if (!seen.includes(newTitle)) {
|
||||||
|
console.error(`[${f.ext}] artifact read failed: ${lastErr}`);
|
||||||
|
}
|
||||||
|
expect(seen, `${f.ext}: the FILE must carry the written metadata`).toContain(newTitle);
|
||||||
|
expect(seen, `${f.ext}: the old title must be replaced`).not.toContain("E2E Original Title");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -53,6 +53,11 @@ type Querier interface {
|
|||||||
// collections page); same live filter as GetCollectionItems, plus the
|
// collections page); same live filter as GetCollectionItems, plus the
|
||||||
// future-hybrid excluded exclusion and an optional library scope.
|
// future-hybrid excluded exclusion and an optional library scope.
|
||||||
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
||||||
|
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
|
||||||
|
// metadata.opf may only be written when NO other non-archived item
|
||||||
|
// shares the directory; otherwise the sidecar goes to
|
||||||
|
// <basename>.opf so one book's metadata can never poison dir-mates.
|
||||||
|
CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error)
|
||||||
// Count unlinked books for a device (ignored rows stay hidden)
|
// Count unlinked books for a device (ignored rows stay hidden)
|
||||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||||
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
||||||
@@ -275,12 +280,24 @@ type Querier interface {
|
|||||||
// Series are grouped PER LIBRARY: the same series name in two libraries
|
// Series are grouped PER LIBRARY: the same series name in two libraries
|
||||||
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||||
// filter enforces per-user library visibility at the source.
|
// filter enforces per-user library visibility at the source.
|
||||||
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
|
//
|
||||||
// transaction share a single NOW() timestamp, leaving many groups tied on
|
// Series-page sort/filter contract (extensible registry in
|
||||||
// MAX(created_at). Postgres does not guarantee tie order between
|
// services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
|
||||||
// executions, so page boundaries could repeat or drop series. The GROUP BY
|
// it filters in WHERE (the group key — counts stay true); AuthorQuery
|
||||||
// keys make the sort total and deterministic.
|
// and Status filter via HAVING so COUNT(*) keeps reporting the whole
|
||||||
|
// series. ViewerUserId scopes the per-user progress aggregates (the rp
|
||||||
|
// join can't multiply rows: reading_progress is UNIQUE per
|
||||||
|
// media_item+user). Sort is a BOUND PARAM compared inside the CASE
|
||||||
|
// cascade — never interpolated — and the GROUP BY tiebreaker stays
|
||||||
|
// REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
|
||||||
|
// transaction share a single NOW() timestamp, leaving many groups tied
|
||||||
|
// on MAX(created_at), and Postgres does not guarantee tie order between
|
||||||
|
// executions, so page boundaries could repeat or drop series.
|
||||||
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
|
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
|
||||||
|
// Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
|
||||||
|
// HAVING so the pagination total matches what the page query returns
|
||||||
|
// under the same filters (Sort is a page-ORDER concern, not a set
|
||||||
|
// membership one, so it is absent here by design).
|
||||||
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
|
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
|
||||||
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||||
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
||||||
|
|||||||
@@ -369,6 +369,32 @@ func (q *Queries) CountCollectionLiveItems(ctx context.Context, arg CountCollect
|
|||||||
return column_1, err
|
return column_1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CountOtherMediaItemsInDir = `-- name: CountOtherMediaItemsInDir :one
|
||||||
|
SELECT COUNT(*)::int
|
||||||
|
FROM media_items
|
||||||
|
WHERE library_id = $1::uuid
|
||||||
|
AND id != $2::uuid
|
||||||
|
AND archived_at IS NULL
|
||||||
|
AND file_path LIKE ($3::text || '%')
|
||||||
|
`
|
||||||
|
|
||||||
|
type CountOtherMediaItemsInDirParams struct {
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
DirPrefix pgtype.Text `db:"dir_prefix" json:"dir_prefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
|
||||||
|
// metadata.opf may only be written when NO other non-archived item
|
||||||
|
// shares the directory; otherwise the sidecar goes to
|
||||||
|
// <basename>.opf so one book's metadata can never poison dir-mates.
|
||||||
|
func (q *Queries) CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CountOtherMediaItemsInDir, arg.LibraryID, arg.ID, arg.DirPrefix)
|
||||||
|
var column_1 int32
|
||||||
|
err := row.Scan(&column_1)
|
||||||
|
return column_1, err
|
||||||
|
}
|
||||||
|
|
||||||
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
|
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
|
||||||
SELECT COUNT(*) as count
|
SELECT COUNT(*) as count
|
||||||
FROM unlinked_books
|
FROM unlinked_books
|
||||||
@@ -5266,21 +5292,71 @@ func (q *Queries) GetDictionaryEntry(ctx context.Context, word string) (Dictiona
|
|||||||
const GetDistinctSeries = `-- name: GetDistinctSeries :many
|
const GetDistinctSeries = `-- name: GetDistinctSeries :many
|
||||||
SELECT library_id, series, COUNT(*) as book_count,
|
SELECT library_id, series, COUNT(*) as book_count,
|
||||||
MAX(series_count) as total_in_series,
|
MAX(series_count) as total_in_series,
|
||||||
MAX(created_at) as last_entry_at
|
MAX(created_at) as last_entry_at,
|
||||||
|
COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count,
|
||||||
|
MAX(rp.last_read_at) AS last_read_at
|
||||||
FROM media_items
|
FROM media_items
|
||||||
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
|
LEFT JOIN reading_progress rp
|
||||||
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
|
ON rp.media_item_id = media_items.id
|
||||||
|
AND rp.user_id = $1::uuid
|
||||||
|
WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
|
||||||
|
AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[]))
|
||||||
AND archived_at IS NULL
|
AND archived_at IS NULL
|
||||||
AND missing_scan_count = 0
|
AND missing_scan_count = 0
|
||||||
AND series IS NOT NULL AND series != ''
|
AND series IS NOT NULL AND series != ''
|
||||||
|
-- Multi-field search (series page): the query matches the series
|
||||||
|
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
|
||||||
|
-- a WHERE filter: a group qualifies when ANY of its rows matches,
|
||||||
|
-- so COUNT(*) keeps reporting the whole series.
|
||||||
|
AND ($4::text IS NULL OR
|
||||||
|
series ILIKE '%' || $4::text || '%'
|
||||||
|
OR author ILIKE '%' || $4::text || '%'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM media_items sib
|
||||||
|
WHERE sib.library_id = media_items.library_id
|
||||||
|
AND sib.series = media_items.series
|
||||||
|
AND sib.title ILIKE '%' || $4::text || '%'
|
||||||
|
))
|
||||||
GROUP BY library_id, series
|
GROUP BY library_id, series
|
||||||
ORDER BY MAX(created_at) DESC, library_id, series
|
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
|
||||||
LIMIT $4 OFFSET $3
|
AND (
|
||||||
|
$6::text IS NULL
|
||||||
|
OR CASE $6::text
|
||||||
|
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
||||||
|
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
||||||
|
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
||||||
|
ELSE true
|
||||||
|
END
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN COALESCE($7::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST,
|
||||||
|
CASE WHEN $7::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST,
|
||||||
|
CASE WHEN $7::text = 'name_asc' THEN series END ASC NULLS LAST,
|
||||||
|
CASE WHEN $7::text = 'name_desc' THEN series END DESC NULLS LAST,
|
||||||
|
CASE WHEN $7::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST,
|
||||||
|
CASE WHEN $7::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST,
|
||||||
|
CASE WHEN $7::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST,
|
||||||
|
CASE WHEN $7::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST,
|
||||||
|
CASE WHEN $7::text = 'progress_desc'
|
||||||
|
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
||||||
|
END DESC NULLS LAST,
|
||||||
|
CASE WHEN $7::text = 'progress_asc'
|
||||||
|
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
||||||
|
END ASC NULLS LAST,
|
||||||
|
CASE WHEN $7::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST,
|
||||||
|
CASE WHEN $7::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST,
|
||||||
|
library_id, series
|
||||||
|
LIMIT $9 OFFSET $8
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetDistinctSeriesParams struct {
|
type GetDistinctSeriesParams struct {
|
||||||
|
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
||||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
||||||
|
SeriesQuery pgtype.Text `db:"series_query" json:"series_query"`
|
||||||
|
AuthorQuery pgtype.Text `db:"author_query" json:"author_query"`
|
||||||
|
Status pgtype.Text `db:"status" json:"status"`
|
||||||
|
Sort pgtype.Text `db:"sort" json:"sort"`
|
||||||
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
||||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||||
}
|
}
|
||||||
@@ -5291,20 +5367,35 @@ type GetDistinctSeriesRow struct {
|
|||||||
BookCount int64 `db:"book_count" json:"book_count"`
|
BookCount int64 `db:"book_count" json:"book_count"`
|
||||||
TotalInSeries interface{} `db:"total_in_series" json:"total_in_series"`
|
TotalInSeries interface{} `db:"total_in_series" json:"total_in_series"`
|
||||||
LastEntryAt interface{} `db:"last_entry_at" json:"last_entry_at"`
|
LastEntryAt interface{} `db:"last_entry_at" json:"last_entry_at"`
|
||||||
|
FinishedCount int64 `db:"finished_count" json:"finished_count"`
|
||||||
|
LastReadAt interface{} `db:"last_read_at" json:"last_read_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Series are grouped PER LIBRARY: the same series name in two libraries
|
// Series are grouped PER LIBRARY: the same series name in two libraries
|
||||||
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||||
// filter enforces per-user library visibility at the source.
|
// filter enforces per-user library visibility at the source.
|
||||||
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
|
//
|
||||||
// transaction share a single NOW() timestamp, leaving many groups tied on
|
// Series-page sort/filter contract (extensible registry in
|
||||||
// MAX(created_at). Postgres does not guarantee tie order between
|
// services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
|
||||||
// executions, so page boundaries could repeat or drop series. The GROUP BY
|
// it filters in WHERE (the group key — counts stay true); AuthorQuery
|
||||||
// keys make the sort total and deterministic.
|
// and Status filter via HAVING so COUNT(*) keeps reporting the whole
|
||||||
|
// series. ViewerUserId scopes the per-user progress aggregates (the rp
|
||||||
|
// join can't multiply rows: reading_progress is UNIQUE per
|
||||||
|
// media_item+user). Sort is a BOUND PARAM compared inside the CASE
|
||||||
|
// cascade — never interpolated — and the GROUP BY tiebreaker stays
|
||||||
|
// REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
|
||||||
|
// transaction share a single NOW() timestamp, leaving many groups tied
|
||||||
|
// on MAX(created_at), and Postgres does not guarantee tie order between
|
||||||
|
// executions, so page boundaries could repeat or drop series.
|
||||||
func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) {
|
func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) {
|
||||||
rows, err := q.db.Query(ctx, GetDistinctSeries,
|
rows, err := q.db.Query(ctx, GetDistinctSeries,
|
||||||
|
arg.ViewerUserID,
|
||||||
arg.LibraryID,
|
arg.LibraryID,
|
||||||
arg.VisibleLibraryIds,
|
arg.VisibleLibraryIds,
|
||||||
|
arg.SeriesQuery,
|
||||||
|
arg.AuthorQuery,
|
||||||
|
arg.Status,
|
||||||
|
arg.Sort,
|
||||||
arg.Offset,
|
arg.Offset,
|
||||||
arg.Limit,
|
arg.Limit,
|
||||||
)
|
)
|
||||||
@@ -5321,6 +5412,8 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
|
|||||||
&i.BookCount,
|
&i.BookCount,
|
||||||
&i.TotalInSeries,
|
&i.TotalInSeries,
|
||||||
&i.LastEntryAt,
|
&i.LastEntryAt,
|
||||||
|
&i.FinishedCount,
|
||||||
|
&i.LastReadAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -5333,22 +5426,62 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetDistinctSeriesCount = `-- name: GetDistinctSeriesCount :one
|
const GetDistinctSeriesCount = `-- name: GetDistinctSeriesCount :one
|
||||||
SELECT COUNT(DISTINCT (library_id, series))::int
|
SELECT COUNT(*)::int FROM (
|
||||||
|
SELECT library_id, series
|
||||||
FROM media_items
|
FROM media_items
|
||||||
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
|
LEFT JOIN reading_progress rp
|
||||||
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
|
ON rp.media_item_id = media_items.id
|
||||||
|
AND rp.user_id = $1::uuid
|
||||||
|
WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
|
||||||
|
AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[]))
|
||||||
AND archived_at IS NULL
|
AND archived_at IS NULL
|
||||||
AND missing_scan_count = 0
|
AND missing_scan_count = 0
|
||||||
AND series IS NOT NULL AND series != ''
|
AND series IS NOT NULL AND series != ''
|
||||||
|
AND ($4::text IS NULL OR
|
||||||
|
series ILIKE '%' || $4::text || '%'
|
||||||
|
OR author ILIKE '%' || $4::text || '%'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM media_items sib
|
||||||
|
WHERE sib.library_id = media_items.library_id
|
||||||
|
AND sib.series = media_items.series
|
||||||
|
AND sib.title ILIKE '%' || $4::text || '%'
|
||||||
|
))
|
||||||
|
GROUP BY library_id, series
|
||||||
|
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
|
||||||
|
AND (
|
||||||
|
$6::text IS NULL
|
||||||
|
OR CASE $6::text
|
||||||
|
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
||||||
|
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
||||||
|
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
||||||
|
ELSE true
|
||||||
|
END
|
||||||
|
)
|
||||||
|
) groups
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetDistinctSeriesCountParams struct {
|
type GetDistinctSeriesCountParams struct {
|
||||||
|
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
||||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
||||||
|
SeriesQuery pgtype.Text `db:"series_query" json:"series_query"`
|
||||||
|
AuthorQuery pgtype.Text `db:"author_query" json:"author_query"`
|
||||||
|
Status pgtype.Text `db:"status" json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
|
||||||
|
// HAVING so the pagination total matches what the page query returns
|
||||||
|
// under the same filters (Sort is a page-ORDER concern, not a set
|
||||||
|
// membership one, so it is absent here by design).
|
||||||
func (q *Queries) GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error) {
|
func (q *Queries) GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error) {
|
||||||
row := q.db.QueryRow(ctx, GetDistinctSeriesCount, arg.LibraryID, arg.VisibleLibraryIds)
|
row := q.db.QueryRow(ctx, GetDistinctSeriesCount,
|
||||||
|
arg.ViewerUserID,
|
||||||
|
arg.LibraryID,
|
||||||
|
arg.VisibleLibraryIds,
|
||||||
|
arg.SeriesQuery,
|
||||||
|
arg.AuthorQuery,
|
||||||
|
arg.Status,
|
||||||
|
)
|
||||||
var column_1 int32
|
var column_1 int32
|
||||||
err := row.Scan(&column_1)
|
err := row.Scan(&column_1)
|
||||||
return column_1, err
|
return column_1, err
|
||||||
@@ -14606,11 +14739,17 @@ SELECT
|
|||||||
mi.opf_uuid,
|
mi.opf_uuid,
|
||||||
mi.hash_confidence,
|
mi.hash_confidence,
|
||||||
l.name as library_name,
|
l.name as library_name,
|
||||||
lt.name as library_type_name
|
lt.name as library_type_name,
|
||||||
|
-- Viewer's own progress (search-result badges): NULL when the
|
||||||
|
-- requester never opened the book. Reuses the SAME requester param
|
||||||
|
-- the visibility join binds; UNIQUE(media_item, user) keeps the
|
||||||
|
-- join row-neutral.
|
||||||
|
rp.percentage as viewer_percentage
|
||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||||
|
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
@@ -14876,6 +15015,7 @@ type SearchMediaItemsUnifiedRow struct {
|
|||||||
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
||||||
LibraryName string `db:"library_name" json:"library_name"`
|
LibraryName string `db:"library_name" json:"library_name"`
|
||||||
LibraryTypeName string `db:"library_type_name" json:"library_type_name"`
|
LibraryTypeName string `db:"library_type_name" json:"library_type_name"`
|
||||||
|
ViewerPercentage pgtype.Float8 `db:"viewer_percentage" json:"viewer_percentage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error) {
|
func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error) {
|
||||||
@@ -14971,6 +15111,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
|
|||||||
&i.HashConfidence,
|
&i.HashConfidence,
|
||||||
&i.LibraryName,
|
&i.LibraryName,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
|
&i.ViewerPercentage,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -734,11 +734,17 @@ SELECT
|
|||||||
mi.opf_uuid,
|
mi.opf_uuid,
|
||||||
mi.hash_confidence,
|
mi.hash_confidence,
|
||||||
l.name as library_name,
|
l.name as library_name,
|
||||||
lt.name as library_type_name
|
lt.name as library_type_name,
|
||||||
|
-- Viewer's own progress (search-result badges): NULL when the
|
||||||
|
-- requester never opened the book. Reuses the SAME requester param
|
||||||
|
-- the visibility join binds; UNIQUE(media_item, user) keeps the
|
||||||
|
-- join row-neutral.
|
||||||
|
rp.percentage as viewer_percentage
|
||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
|
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
AND mi.archived_at IS NULL
|
AND mi.archived_at IS NULL
|
||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
@@ -4071,32 +4077,126 @@ ORDER BY mi.created_at DESC;
|
|||||||
-- Series are grouped PER LIBRARY: the same series name in two libraries
|
-- Series are grouped PER LIBRARY: the same series name in two libraries
|
||||||
-- (novels vs manga adaptations, LN splits) stays two entries. Visible
|
-- (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||||
-- filter enforces per-user library visibility at the source.
|
-- filter enforces per-user library visibility at the source.
|
||||||
|
--
|
||||||
|
-- Series-page sort/filter contract (extensible registry in
|
||||||
|
-- services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
|
||||||
|
-- it filters in WHERE (the group key — counts stay true); AuthorQuery
|
||||||
|
-- and Status filter via HAVING so COUNT(*) keeps reporting the whole
|
||||||
|
-- series. ViewerUserId scopes the per-user progress aggregates (the rp
|
||||||
|
-- join can't multiply rows: reading_progress is UNIQUE per
|
||||||
|
-- media_item+user). Sort is a BOUND PARAM compared inside the CASE
|
||||||
|
-- cascade — never interpolated — and the GROUP BY tiebreaker stays
|
||||||
|
-- REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
|
||||||
|
-- transaction share a single NOW() timestamp, leaving many groups tied
|
||||||
|
-- on MAX(created_at), and Postgres does not guarantee tie order between
|
||||||
|
-- executions, so page boundaries could repeat or drop series.
|
||||||
SELECT library_id, series, COUNT(*) as book_count,
|
SELECT library_id, series, COUNT(*) as book_count,
|
||||||
MAX(series_count) as total_in_series,
|
MAX(series_count) as total_in_series,
|
||||||
MAX(created_at) as last_entry_at
|
MAX(created_at) as last_entry_at,
|
||||||
|
COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count,
|
||||||
|
MAX(rp.last_read_at) AS last_read_at
|
||||||
FROM media_items
|
FROM media_items
|
||||||
|
LEFT JOIN reading_progress rp
|
||||||
|
ON rp.media_item_id = media_items.id
|
||||||
|
AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
AND archived_at IS NULL
|
AND archived_at IS NULL
|
||||||
AND missing_scan_count = 0
|
AND missing_scan_count = 0
|
||||||
AND series IS NOT NULL AND series != ''
|
AND series IS NOT NULL AND series != ''
|
||||||
|
-- Multi-field search (series page): the query matches the series
|
||||||
|
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
|
||||||
|
-- a WHERE filter: a group qualifies when ANY of its rows matches,
|
||||||
|
-- so COUNT(*) keeps reporting the whole series.
|
||||||
|
AND (sqlc.narg('series_query')::text IS NULL OR
|
||||||
|
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
||||||
|
OR author ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM media_items sib
|
||||||
|
WHERE sib.library_id = media_items.library_id
|
||||||
|
AND sib.series = media_items.series
|
||||||
|
AND sib.title ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
||||||
|
))
|
||||||
GROUP BY library_id, series
|
GROUP BY library_id, series
|
||||||
-- Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
|
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
|
||||||
-- transaction share a single NOW() timestamp, leaving many groups tied on
|
AND (
|
||||||
-- MAX(created_at). Postgres does not guarantee tie order between
|
sqlc.narg('status')::text IS NULL
|
||||||
-- executions, so page boundaries could repeat or drop series. The GROUP BY
|
OR CASE sqlc.narg('status')::text
|
||||||
-- keys make the sort total and deterministic.
|
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
||||||
ORDER BY MAX(created_at) DESC, library_id, series
|
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
||||||
|
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
||||||
|
ELSE true
|
||||||
|
END
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN COALESCE(sqlc.narg('sort')::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST,
|
||||||
|
CASE WHEN sqlc.narg('sort')::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST,
|
||||||
|
CASE WHEN sqlc.narg('sort')::text = 'name_asc' THEN series END ASC NULLS LAST,
|
||||||
|
CASE WHEN sqlc.narg('sort')::text = 'name_desc' THEN series END DESC NULLS LAST,
|
||||||
|
CASE WHEN sqlc.narg('sort')::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST,
|
||||||
|
CASE WHEN sqlc.narg('sort')::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST,
|
||||||
|
CASE WHEN sqlc.narg('sort')::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST,
|
||||||
|
CASE WHEN sqlc.narg('sort')::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST,
|
||||||
|
CASE WHEN sqlc.narg('sort')::text = 'progress_desc'
|
||||||
|
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
||||||
|
END DESC NULLS LAST,
|
||||||
|
CASE WHEN sqlc.narg('sort')::text = 'progress_asc'
|
||||||
|
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
||||||
|
END ASC NULLS LAST,
|
||||||
|
CASE WHEN sqlc.narg('sort')::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST,
|
||||||
|
CASE WHEN sqlc.narg('sort')::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST,
|
||||||
|
library_id, series
|
||||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||||
|
|
||||||
-- name: GetDistinctSeriesCount :one
|
-- name: GetDistinctSeriesCount :one
|
||||||
SELECT COUNT(DISTINCT (library_id, series))::int
|
-- Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
|
||||||
|
-- HAVING so the pagination total matches what the page query returns
|
||||||
|
-- under the same filters (Sort is a page-ORDER concern, not a set
|
||||||
|
-- membership one, so it is absent here by design).
|
||||||
|
SELECT COUNT(*)::int FROM (
|
||||||
|
SELECT library_id, series
|
||||||
FROM media_items
|
FROM media_items
|
||||||
|
LEFT JOIN reading_progress rp
|
||||||
|
ON rp.media_item_id = media_items.id
|
||||||
|
AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
AND archived_at IS NULL
|
AND archived_at IS NULL
|
||||||
AND missing_scan_count = 0
|
AND missing_scan_count = 0
|
||||||
AND series IS NOT NULL AND series != '';
|
AND series IS NOT NULL AND series != ''
|
||||||
|
AND (sqlc.narg('series_query')::text IS NULL OR
|
||||||
|
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
||||||
|
OR author ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM media_items sib
|
||||||
|
WHERE sib.library_id = media_items.library_id
|
||||||
|
AND sib.series = media_items.series
|
||||||
|
AND sib.title ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
||||||
|
))
|
||||||
|
GROUP BY library_id, series
|
||||||
|
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
|
||||||
|
AND (
|
||||||
|
sqlc.narg('status')::text IS NULL
|
||||||
|
OR CASE sqlc.narg('status')::text
|
||||||
|
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
||||||
|
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
||||||
|
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
||||||
|
ELSE true
|
||||||
|
END
|
||||||
|
)
|
||||||
|
) groups;
|
||||||
|
|
||||||
|
-- name: CountOtherMediaItemsInDir :one
|
||||||
|
-- Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
|
||||||
|
-- metadata.opf may only be written when NO other non-archived item
|
||||||
|
-- shares the directory; otherwise the sidecar goes to
|
||||||
|
-- <basename>.opf so one book's metadata can never poison dir-mates.
|
||||||
|
SELECT COUNT(*)::int
|
||||||
|
FROM media_items
|
||||||
|
WHERE library_id = sqlc.narg('library_id')::uuid
|
||||||
|
AND id != sqlc.narg('id')::uuid
|
||||||
|
AND archived_at IS NULL
|
||||||
|
AND file_path LIKE (sqlc.narg('dir_prefix')::text || '%');
|
||||||
|
|
||||||
-- name: GetSeriesCovers :many
|
-- name: GetSeriesCovers :many
|
||||||
SELECT cover_image_path, library_id
|
SELECT cover_image_path, library_id
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ var Schemas = map[string]Schema{
|
|||||||
"POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": {
|
"POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": {
|
||||||
Response: json.RawMessage(`{"success": false, "message": ""}`),
|
Response: json.RawMessage(`{"success": false, "message": ""}`),
|
||||||
},
|
},
|
||||||
|
"POST /api/libraries/:id/issues/:issueId/retry": {
|
||||||
|
Response: json.RawMessage(`{"success": false, "message": ""}`),
|
||||||
|
},
|
||||||
"DELETE /api/libraries/:id/issues/:issueId": {
|
"DELETE /api/libraries/:id/issues/:issueId": {
|
||||||
Response: json.RawMessage(`{"success": false, "message": ""}`),
|
Response: json.RawMessage(`{"success": false, "message": ""}`),
|
||||||
},
|
},
|
||||||
|
|||||||
+19
-17
@@ -10,6 +10,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"html/template"
|
"html/template"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -335,13 +336,9 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
html := fmt.Sprintf(`<div class="text-green-500">Registration successful! Redirecting...</div>
|
// CSP: see the login handler — data attributes, not inline scripts.
|
||||||
<script>
|
html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="/dashboard">Registration successful! Redirecting...</div>`,
|
||||||
localStorage.setItem('token', '%s');
|
accessToken)
|
||||||
localStorage.setItem('refreshToken', '%s');
|
|
||||||
localStorage.setItem('user', JSON.stringify(%s));
|
|
||||||
window.location.href = '/dashboard';
|
|
||||||
</script>`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username))
|
|
||||||
return c.HTML(http.StatusCreated, html)
|
return c.HTML(http.StatusCreated, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +365,10 @@ window.location.href = '/dashboard';
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Login handles POST /api/auth/login
|
// Login handles POST /api/auth/login
|
||||||
|
// invalidCredentialsHTML is the htmx error partial for failed logins;
|
||||||
|
// web/src/auth_forms.ts swaps 4xx/5xx HTML responses into the form target.
|
||||||
|
const invalidCredentialsHTML = `<div class="text-red-500">Invalid email/username or password.</div>`
|
||||||
|
|
||||||
func (h *AuthHandler) Login(c *echo.Context) error {
|
func (h *AuthHandler) Login(c *echo.Context) error {
|
||||||
login := c.FormValue("login")
|
login := c.FormValue("login")
|
||||||
password := c.FormValue("password")
|
password := c.FormValue("password")
|
||||||
@@ -424,7 +425,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
return c.HTML(http.StatusUnauthorized, invalidCredentialsHTML)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||||
}
|
}
|
||||||
@@ -440,7 +441,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
return c.HTML(http.StatusUnauthorized, invalidCredentialsHTML)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||||
}
|
}
|
||||||
@@ -481,17 +482,18 @@ func (h *AuthHandler) Login(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
|
// CSP: hash-mode script-src refuses inline scripts, so the htmx
|
||||||
|
// partial carries data attributes instead; web/src/auth_forms.ts
|
||||||
|
// stashes the token and navigates on htmx:afterSwap. The redirect
|
||||||
|
// target is path-only (open-redirect hygiene) and the old
|
||||||
|
// 'user'/'refreshToken' localStorage writes are gone — nothing
|
||||||
|
// ever read them.
|
||||||
redirect := c.FormValue("redirect")
|
redirect := c.FormValue("redirect")
|
||||||
if redirect == "" {
|
if redirect == "" || !strings.HasPrefix(redirect, "/") {
|
||||||
redirect = "/dashboard"
|
redirect = "/dashboard"
|
||||||
}
|
}
|
||||||
html := fmt.Sprintf(`<div class="text-green-500">Login successful! Redirecting...</div>
|
html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="%s">Login successful! Redirecting...</div>`,
|
||||||
<script>
|
accessToken, html.EscapeString(redirect))
|
||||||
localStorage.setItem('token', '%s');
|
|
||||||
localStorage.setItem('refreshToken', '%s');
|
|
||||||
localStorage.setItem('user', JSON.stringify(%s));
|
|
||||||
window.location.href = '%s';
|
|
||||||
</script>`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s","first_name":"%s","last_name":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username, user.FirstName.String, user.LastName.String), redirect)
|
|
||||||
return c.HTML(http.StatusOK, html)
|
return c.HTML(http.StatusOK, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
// BookInfo is THE canonical wire+template card shape: every surface that
|
// BookInfo is THE canonical wire+template card shape: every surface that
|
||||||
// lists a book card as JSON (collections detail, series, preview,
|
// lists a book card as JSON (collections detail, series, preview,
|
||||||
// dashboard sections, search) or renders a templ card (BookCard in
|
// dashboard sections, search) or renders a templ card (BookCard in
|
||||||
// dashboard.templ, BooksGrid, BrowseDetail) carries exactly these seven
|
// dashboard.templ, BooksGrid, BrowseDetail) carries exactly these nine
|
||||||
// keys and no others — pinned by docs/contract_pins.md.
|
// keys and no others — pinned by docs/contract_pins.md.
|
||||||
//
|
//
|
||||||
// H2 doctrine: this struct is constructed in EXACTLY ONE place —
|
// H2 doctrine: this struct is constructed in EXACTLY ONE place —
|
||||||
@@ -34,6 +34,15 @@ type BookInfo struct {
|
|||||||
// comic vs manga rides the LIBRARY type (format_group only knows
|
// comic vs manga rides the LIBRARY type (format_group only knows
|
||||||
// comic_archive — manga_type is unreliable across sources).
|
// comic_archive — manga_type is unreliable across sources).
|
||||||
Badge string `json:"badge"`
|
Badge string `json:"badge"`
|
||||||
|
// Viewer-scoped extras (2026-10-27 search-progress batch): nil
|
||||||
|
// percentage = the requesting user never opened the book, so cards
|
||||||
|
// render no progress bar and no completed badge. Adapters that
|
||||||
|
// don't load viewer progress leave it nil; the search-unified
|
||||||
|
// adapter is the only populated feed today. LibraryName rides the
|
||||||
|
// same deal (unified rows carry it as a column; other feeds leave
|
||||||
|
// it empty rather than pay a join).
|
||||||
|
ViewerPercentage *float64 `json:"viewer_percentage"`
|
||||||
|
LibraryName string `json:"library_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
|
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
|
||||||
@@ -85,6 +94,8 @@ func buildBookCard(src cardSource) BookInfo {
|
|||||||
CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
|
CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
|
||||||
FormatGroup: src.FormatGroup,
|
FormatGroup: src.FormatGroup,
|
||||||
Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
|
Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
|
||||||
|
ViewerPercentage: src.ViewerProgress,
|
||||||
|
LibraryName: src.LibraryName,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +113,10 @@ type cardSource struct {
|
|||||||
CoverPath pgtype.Text
|
CoverPath pgtype.Text
|
||||||
FormatGroup string
|
FormatGroup string
|
||||||
LibraryType string
|
LibraryType string
|
||||||
|
// Viewer-scoped extras: nil progress = no data loaded for the
|
||||||
|
// requesting user (see BookInfo.ViewerPercentage).
|
||||||
|
ViewerProgress *float64
|
||||||
|
LibraryName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// BookCardsFromMediaItems builds cards from full media rows
|
// BookCardsFromMediaItems builds cards from full media rows
|
||||||
@@ -167,10 +182,17 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
|
|||||||
|
|
||||||
// BookCardsFromUnified builds cards from search-unified rows. Those
|
// BookCardsFromUnified builds cards from search-unified rows. Those
|
||||||
// rows carry the library type name as a SQL column, so this adapter
|
// rows carry the library type name as a SQL column, so this adapter
|
||||||
// needs no LibraryTypeMap query at all.
|
// needs no LibraryTypeMap query at all — and the viewer's own
|
||||||
|
// progress (the 2026-10-27 search-progress batch), which no other
|
||||||
|
// feed loads.
|
||||||
func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo {
|
func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo {
|
||||||
cards := make([]BookInfo, 0, len(rows))
|
cards := make([]BookInfo, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
|
var viewer *float64
|
||||||
|
if r.ViewerPercentage.Valid {
|
||||||
|
pct := r.ViewerPercentage.Float64
|
||||||
|
viewer = &pct
|
||||||
|
}
|
||||||
cards = append(cards, buildBookCard(cardSource{
|
cards = append(cards, buildBookCard(cardSource{
|
||||||
ID: uuid.UUID(r.ID.Bytes),
|
ID: uuid.UUID(r.ID.Bytes),
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
@@ -179,6 +201,8 @@ func BookCardsFromUnified(rows []database.SearchMediaItemsUnifiedRow) []BookInfo
|
|||||||
CoverPath: r.CoverImagePath,
|
CoverPath: r.CoverImagePath,
|
||||||
FormatGroup: r.FormatGroup,
|
FormatGroup: r.FormatGroup,
|
||||||
LibraryType: r.LibraryTypeName,
|
LibraryType: r.LibraryTypeName,
|
||||||
|
ViewerProgress: viewer,
|
||||||
|
LibraryName: r.LibraryName,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
return cards
|
return cards
|
||||||
|
|||||||
@@ -496,7 +496,8 @@ func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
html := `<div class="text-green-500">Collection deleted! Redirecting...</div><script>window.location.href = '/collections';</script>`
|
// CSP: no inline scripts in partials — auth_forms.ts navigates.
|
||||||
|
html := `<div class="text-green-500" data-redirect="/collections">Collection deleted! Redirecting...</div>`
|
||||||
return c.HTML(http.StatusOK, html)
|
return c.HTML(http.StatusOK, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -499,7 +499,10 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
|
|||||||
|
|
||||||
// Handle HTMX requests - return HTML with page reload script
|
// Handle HTMX requests - return HTML with page reload script
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
html := `<div class="text-green-500">Token regenerated! Reloading ... </div><script>window.location.reload();`
|
// CSP: no inline scripts in partials; HX-Refresh makes htmx
|
||||||
|
// reload the page natively (the old script tag was also unclosed).
|
||||||
|
c.Response().Header().Set("HX-Refresh", "true")
|
||||||
|
html := `<div class="text-green-500">Token regenerated! Reloading ... </div>`
|
||||||
return c.HTML(http.StatusOK, html)
|
return c.HTML(http.StatusOK, html)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
|||||||
@@ -1801,6 +1801,57 @@ func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, item)
|
return c.JSON(http.StatusOK, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteMetadataToFile handles POST /api/media-items/:id/write-metadata
|
||||||
|
// (admin only). Persists the item's CURRENT database metadata into the
|
||||||
|
// artifact on disk — embedded OPF for EPUBs, a Calibre-style sidecar
|
||||||
|
// for formats that can't carry metadata (2026-10-27 feature) — then
|
||||||
|
// re-extracts from the file so the scan-derived columns agree with
|
||||||
|
// what was written. metadata_overrides are NOT reset: after the write,
|
||||||
|
// file and DB agree, and the overrides keep stale sidecars from
|
||||||
|
// clobbering future rescans.
|
||||||
|
func (mh *MediaHandler) WriteMetadataToFile(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
|
||||||
|
if user.Role != "admin" {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
writer := services.NewMetadataWriter(mh.db)
|
||||||
|
result, err := writer.WriteMetadataToFile(c.Request().Context(), mediaUUID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-extract so the scan pipeline sees the file we just wrote. A
|
||||||
|
// rescan failure doesn't undo the write — it surfaces separately.
|
||||||
|
rescanStatus := "ok"
|
||||||
|
scanner := services.NewMediaScanner(mh.db)
|
||||||
|
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, false); err != nil {
|
||||||
|
rescanStatus = "error: " + err.Error()
|
||||||
|
}
|
||||||
|
defer scanner.Close()
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"write": result,
|
||||||
|
"rescan": rescanStatus,
|
||||||
|
"metadata_target": string(result.Target),
|
||||||
|
"fields_written": result.FieldsWritten,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
|
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
|
||||||
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
||||||
user := MustGetAuthenticatedUser(c)
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -218,6 +220,77 @@ func (h *ProcessingIssuesHandler) gateIssue(c *echo.Context, issueID uuid.UUID)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetryProcessingIssue re-runs the work that produced an open issue
|
||||||
|
// through the individual scanner — NOT a library-wide rescan:
|
||||||
|
//
|
||||||
|
// - item-backed issues rescan their media item (the book-detail
|
||||||
|
// RescanMediaItem mechanism: self-resolving folders, multi-file
|
||||||
|
// audiobook routing, write-after-success guards);
|
||||||
|
// - row-less import_failed issues re-import the single file through
|
||||||
|
// the watcher's standalone processMediaFile path.
|
||||||
|
//
|
||||||
|
// Success closes the issue (explicitly for the item shape; the create
|
||||||
|
// path's self-heal plus the explicit path resolve below for row-less).
|
||||||
|
// Failure returns the REAL error so the UI can say why it still fails.
|
||||||
|
func (h *ProcessingIssuesHandler) RetryProcessingIssue(c *echo.Context) error {
|
||||||
|
issueID, err := uuid.Parse(c.Param("issueId"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
issue, err := h.db.GetProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Issue not found"})
|
||||||
|
}
|
||||||
|
if !issue.LibraryID.Valid {
|
||||||
|
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has no library attribution"})
|
||||||
|
}
|
||||||
|
if err := h.gateIssue(c, issueID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if issue.Resolved.Valid && issue.Resolved.Bool {
|
||||||
|
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue is already resolved"})
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := services.NewMediaScanner(h.db)
|
||||||
|
defer scanner.Close()
|
||||||
|
|
||||||
|
if issue.MediaItemID.Valid {
|
||||||
|
if err := scanner.RetryMediaItemIssue(c.Request().Context(), issue.MediaItemID); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"message": "Item rescanned; issue resolved",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row-less import failure: the file never became a row.
|
||||||
|
if !issue.FilePath.Valid || issue.FilePath.String == "" {
|
||||||
|
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has neither a media item nor a file path"})
|
||||||
|
}
|
||||||
|
relativePath := issue.FilePath.String
|
||||||
|
if err := scanner.RetryFailedImport(c.Request().Context(), issue.LibraryID, relativePath); err != nil {
|
||||||
|
if errors.Is(err, services.ErrRetryFileMissing) {
|
||||||
|
return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
// The import chain's self-heal closes path-keyed issues on create;
|
||||||
|
// an already-imported duplicate (a previous partial retry) skips
|
||||||
|
// that path, so close explicitly here too. Idempotent.
|
||||||
|
if _, err := h.db.ResolveOpenProcessingIssuesForPath(c.Request().Context(), database.ResolveOpenProcessingIssuesForPathParams{
|
||||||
|
LibraryID: issue.LibraryID,
|
||||||
|
FilePath: pgtype.Text{String: relativePath, Valid: true},
|
||||||
|
}); err != nil {
|
||||||
|
fmt.Printf("Warning: retry imported %s but failed to close its issues: %v\n", relativePath, err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"message": "File imported; issue resolved",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
|
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
|
||||||
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
|
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
|
||||||
return h.db.GetProcessingIssueStats(ctx, libraryID)
|
return h.db.GetProcessingIssueStats(ctx, libraryID)
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ func (h *Handler) StartBackgroundTasks() {
|
|||||||
type ScanLibraryRequest struct {
|
type ScanLibraryRequest struct {
|
||||||
FolderPaths []string `json:"folder_paths,omitempty"`
|
FolderPaths []string `json:"folder_paths,omitempty"`
|
||||||
LibraryID string `json:"library_id,omitempty"`
|
LibraryID string `json:"library_id,omitempty"`
|
||||||
|
// 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"`
|
Force bool `json:"force,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,10 +73,29 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.FolderPath != "" {
|
||||||
|
// Per-folder scope (LibraryPanel per-folder Scan buttons):
|
||||||
|
// scan exactly one registered folder's subtree. The archive
|
||||||
|
// reconciliation pass skips partially-covered libraries
|
||||||
|
// (processScanJob → ScanFolders), so the other folders'
|
||||||
|
// items cannot be marked missing by this run.
|
||||||
|
registered := false
|
||||||
|
for _, folder := range libraryFolders {
|
||||||
|
if folder.FolderPath == req.FolderPath {
|
||||||
|
registered = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !registered {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "folder_path is not one of this library's registered folders"})
|
||||||
|
}
|
||||||
|
folderPaths = []string{req.FolderPath}
|
||||||
|
} else {
|
||||||
// Extract folder paths
|
// Extract folder paths
|
||||||
for _, folder := range libraryFolders {
|
for _, folder := range libraryFolders {
|
||||||
folderPaths = append(folderPaths, folder.FolderPath)
|
folderPaths = append(folderPaths, folder.FolderPath)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(folderPaths) == 0 {
|
if len(folderPaths) == 0 {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library has no folders configured"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library has no folders configured"})
|
||||||
|
|||||||
@@ -64,7 +64,20 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||||
}
|
}
|
||||||
|
|
||||||
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset)
|
// Sort/filter params (same contract as the SSR /series page):
|
||||||
|
// unknown values resolve to defaults inside the service.
|
||||||
|
opts := services.SeriesPageOptions{
|
||||||
|
Sort: c.QueryParam("sort"),
|
||||||
|
Dir: c.QueryParam("dir"),
|
||||||
|
Query: c.QueryParam("q"),
|
||||||
|
Author: c.QueryParam("author"),
|
||||||
|
Status: c.QueryParam("status"),
|
||||||
|
}
|
||||||
|
if u, ok := c.Get("user").(database.Users); ok {
|
||||||
|
opts.ViewerUserID = u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
|
||||||
}
|
}
|
||||||
@@ -76,6 +89,8 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
|||||||
TotalInSeries int `json:"total_in_series"`
|
TotalInSeries int `json:"total_in_series"`
|
||||||
CoverPaths []string `json:"cover_paths"`
|
CoverPaths []string `json:"cover_paths"`
|
||||||
LastEntryAt string `json:"last_entry_at"`
|
LastEntryAt string `json:"last_entry_at"`
|
||||||
|
ReadCount int64 `json:"read_count"`
|
||||||
|
LastReadAt string `json:"last_read_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
response := make([]SeriesResponse, 0, len(seriesList))
|
response := make([]SeriesResponse, 0, len(seriesList))
|
||||||
@@ -87,6 +102,8 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
|||||||
TotalInSeries: s.TotalInSeries,
|
TotalInSeries: s.TotalInSeries,
|
||||||
CoverPaths: s.CoverPaths,
|
CoverPaths: s.CoverPaths,
|
||||||
LastEntryAt: s.LastEntryAt,
|
LastEntryAt: s.LastEntryAt,
|
||||||
|
ReadCount: s.ReadCount,
|
||||||
|
LastReadAt: s.LastReadAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +167,7 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
|
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts services.SeriesPageOptions) ([]services.SeriesInfo, int, error) {
|
||||||
svc := services.NewSeriesService(db)
|
svc := services.NewSeriesService(db)
|
||||||
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset)
|
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset, opts)
|
||||||
}
|
}
|
||||||
@@ -489,12 +489,18 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
defaultTimezone = tz
|
defaultTimezone = tz
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render success message with updated form
|
// Render success message with updated form. NOTE: this Go-source
|
||||||
|
// copy is the htmx-swapped twin of the templ form in
|
||||||
|
// templates/admin_settings.templ (handlers cannot import templates:
|
||||||
|
// templates/utils.go imports handlers types — cycle). It MUST keep
|
||||||
|
// the twin's functional attributes — id, hx-*, data-settings-save,
|
||||||
|
// hx-disabled-elt — or save feedback silently dies after the first
|
||||||
|
// swap. e2e/settings-feedback.spec.ts saves twice to pin this.
|
||||||
return c.HTML(http.StatusOK, fmt.Sprintf(`
|
return c.HTML(http.StatusOK, fmt.Sprintf(`
|
||||||
<div class="mb-4 p-4 rounded-lg" style="background-color: var(--bg-secondary); border: 1px solid var(--accent);">
|
<div class="mb-4 p-4 rounded-lg" style="background-color: var(--bg-secondary); border: 1px solid var(--accent);">
|
||||||
<p style="color: var(--text-success);">✅ Settings saved successfully!</p>
|
<p style="color: var(--text-success);">✅ Settings saved successfully!</p>
|
||||||
</div>
|
</div>
|
||||||
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
|
<form id="settings-form" data-settings-save="settings" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
|
||||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Base URL</h3>
|
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Base URL</h3>
|
||||||
|
|
||||||
@@ -513,7 +519,7 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
|
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium" hx-disabled-elt="this">
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -551,7 +557,7 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
|
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
|
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium" hx-disabled-elt="this">
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package middleware_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Go-source CSP tripwire.
|
||||||
|
//
|
||||||
|
// The templ scan lives in templates/csp_source_test.go, but htmx partials
|
||||||
|
// can also be emitted from Go handler strings — auth.go used to answer a
|
||||||
|
// successful login with an inline <script>window.location…</script> that
|
||||||
|
// hash-mode CSP silently refused. This test scans the non-test Go sources
|
||||||
|
// for the same forbidden patterns: inline script tags and native on*=
|
||||||
|
// handler attributes in emitted HTML. Partials that need to navigate or
|
||||||
|
// stash state use data attributes + web/src/auth_forms.ts, or htmx's
|
||||||
|
// HX-Redirect/HX-Refresh headers.
|
||||||
|
|
||||||
|
var goScriptTagRe = regexp.MustCompile(`<script`)
|
||||||
|
|
||||||
|
var goHandlerAttrRe = regexp.MustCompile(
|
||||||
|
`on(click|error|submit|load|change|input|focus|blur|mouseover|keydown|keyup)\s*=\s*"`)
|
||||||
|
|
||||||
|
func TestGoSourcesAreCSPSafe(t *testing.T) {
|
||||||
|
root := "../.."
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
switch d.Name() {
|
||||||
|
case "node_modules", "static", ".git", "testdata", "docs":
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := d.Name()
|
||||||
|
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Generated templ codegen is out of scope: its source of truth is
|
||||||
|
// the .templ file (scanned by templates/csp_source_test.go), and
|
||||||
|
// the codegen legitimately emits external <script src=…> tags.
|
||||||
|
if strings.HasSuffix(name, "_templ.go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
src := string(raw)
|
||||||
|
for _, m := range goScriptTagRe.FindAllStringIndex(src, -1) {
|
||||||
|
t.Errorf("%s:%d: literal <script> in Go source — hash-mode CSP refuses inline scripts in emitted partials; use data attributes + web/src/auth_forms.ts or HX-Redirect/HX-Refresh",
|
||||||
|
path, 1+strings.Count(src[:m[0]], "\n"))
|
||||||
|
}
|
||||||
|
for _, m := range goHandlerAttrRe.FindAllStringIndex(src, -1) {
|
||||||
|
t.Errorf("%s:%d: native on*= handler attribute in Go source — CSP hash-mode refuses it; use Alpine bindings or delegated listeners",
|
||||||
|
path, 1+strings.Count(src[:m[0]], "\n"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -9,7 +11,14 @@ import (
|
|||||||
// the rendered component by security_headers_test.go.
|
// the rendered component by security_headers_test.go.
|
||||||
const ScriptHashThemeFlashGuard = "'sha256-JYIVpFyKdLUtWizMha0v69f6yHds+GbJRBWjvpuECBY='"
|
const ScriptHashThemeFlashGuard = "'sha256-JYIVpFyKdLUtWizMha0v69f6yHds+GbJRBWjvpuECBY='"
|
||||||
|
|
||||||
// SecurityHeadersMiddleware adds security headers to all responses
|
// SecurityHeadersMiddleware adds security headers to all responses.
|
||||||
|
// It also forces HTML pages to revalidate (Cache-Control: no-cache):
|
||||||
|
// SSR pages are dynamic, and heuristic browser caching of a PAGE let a
|
||||||
|
// stale page reference a stale main.js stamp long after the bundle was
|
||||||
|
// fixed — the "search dropdown still broken" report was exactly that.
|
||||||
|
// /api, /static and /uploads keep their own (or absent) caching: the
|
||||||
|
// stamped bundles are content-frozen by URL, media files are immutable
|
||||||
|
// by path.
|
||||||
func SecurityHeadersMiddleware() echo.MiddlewareFunc {
|
func SecurityHeadersMiddleware() echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c *echo.Context) error {
|
return func(c *echo.Context) error {
|
||||||
@@ -24,8 +33,8 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
|
|||||||
//
|
//
|
||||||
// The inline script allow-list: the theme flash guard is the
|
// The inline script allow-list: the theme flash guard is the
|
||||||
// ONLY inline script in the app, and its byte-stable content is
|
// ONLY inline script in the app, and its byte-stable content is
|
||||||
// hashed into script-src (sha256 of the bytes between
|
// hashed into script-src (sha256 of the bytes between the
|
||||||
// <script> and </script> as rendered by
|
// script tags as rendered by
|
||||||
// templates.ThemeFlashGuard()). Editing the guard requires
|
// templates.ThemeFlashGuard()). Editing the guard requires
|
||||||
// recomputing the hash — enforced by
|
// recomputing the hash — enforced by
|
||||||
// internal/middleware/security_headers_test.go, which renders
|
// internal/middleware/security_headers_test.go, which renders
|
||||||
@@ -39,6 +48,19 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
|
|||||||
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||||
|
|
||||||
|
// Pages must always revalidate (see doc comment). The check
|
||||||
|
// runs BEFORE next(): once a templ handler writes the body,
|
||||||
|
// response headers are already on the wire.
|
||||||
|
switch p := c.Request().URL.Path; {
|
||||||
|
case strings.HasPrefix(p, "/api/"),
|
||||||
|
strings.HasPrefix(p, "/static/"),
|
||||||
|
strings.HasPrefix(p, "/uploads/"),
|
||||||
|
p == "/health":
|
||||||
|
// Not a page: keep native caching behavior.
|
||||||
|
default:
|
||||||
|
c.Response().Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"bookhoard/internal/middleware"
|
"bookhoard/internal/middleware"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestThemeFlashGuardHashMatchesCSP pins the CSP script hash to the theme
|
// TestThemeFlashGuardHashMatchesCSP pins the CSP script hash to the theme
|
||||||
@@ -49,3 +53,38 @@ middleware.ScriptHashThemeFlashGuard and the CSP header string.`,
|
|||||||
t.Error("script-src must not re-introduce 'unsafe-inline' alongside the hash")
|
t.Error("script-src must not re-introduce 'unsafe-inline' alongside the hash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTML pages must always revalidate (Cache-Control: no-cache): a
|
||||||
|
// heuristically cached PAGE referenced a stale main.js stamp and kept
|
||||||
|
// the old bundle running long after a fix deployed (the 2026-10-27
|
||||||
|
// "search dropdown still broken" report). Static/API/media routes keep
|
||||||
|
// their native caching — stamped bundles are content-frozen by URL.
|
||||||
|
func TestHTMLPagesRevalidate(t *testing.T) {
|
||||||
|
mw := middleware.SecurityHeadersMiddleware()
|
||||||
|
|
||||||
|
render := func(path string) string {
|
||||||
|
e := echo.New()
|
||||||
|
req, _ := http.NewRequest("GET", path, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
handler := mw(func(c *echo.Context) error {
|
||||||
|
return c.HTML(http.StatusOK, "<html></html>")
|
||||||
|
})
|
||||||
|
if err := handler(c); err != nil {
|
||||||
|
t.Fatalf("handler error: %v", err)
|
||||||
|
}
|
||||||
|
return rec.Header().Get("Cache-Control")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := render("/bookshelf"); got != "no-cache" {
|
||||||
|
t.Errorf("/bookshelf: Cache-Control = %q, want no-cache", got)
|
||||||
|
}
|
||||||
|
if got := render("/series"); got != "no-cache" {
|
||||||
|
t.Errorf("/series: Cache-Control = %q, want no-cache", got)
|
||||||
|
}
|
||||||
|
for _, p := range []string{"/static/main.js", "/api/media-items", "/uploads/library-x/book.epub", "/health"} {
|
||||||
|
if got := render(p); got == "no-cache" {
|
||||||
|
t.Errorf("%s: must keep native caching, got no-cache", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,6 +149,36 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
offset := (page - 1) * perSeriesPage
|
offset := (page - 1) * perSeriesPage
|
||||||
|
|
||||||
|
// Series-page state (URL is the source of truth — plain GET form,
|
||||||
|
// no htmx on this page). Unknown values resolve to defaults in the
|
||||||
|
// service's registries, so hand-edited URLs stay safe. The URL
|
||||||
|
// carries the BASE sort key + direction; opts.Sort gets the
|
||||||
|
// composed registry key (full keys win on re-resolve). With no
|
||||||
|
// sort param at all, state stays empty so default URLs remain
|
||||||
|
// clean (the toolbar treats empty as recent/desc).
|
||||||
|
rawSort := c.QueryParam("sort")
|
||||||
|
sortKey, sortDir := services.ResolveSeriesSort(rawSort, c.QueryParam("dir"))
|
||||||
|
state := templates.SeriesPageState{
|
||||||
|
Sort: services.SeriesSortBase(sortKey),
|
||||||
|
Dir: sortDir,
|
||||||
|
Query: c.QueryParam("q"),
|
||||||
|
Status: c.QueryParam("status"),
|
||||||
|
}
|
||||||
|
if rawSort == "" {
|
||||||
|
state.Sort = ""
|
||||||
|
state.Dir = ""
|
||||||
|
}
|
||||||
|
opts := services.SeriesPageOptions{
|
||||||
|
Sort: sortKey,
|
||||||
|
Dir: sortDir,
|
||||||
|
Query: state.Query,
|
||||||
|
Author: c.QueryParam("author"), // API/deep-link back-compat; the toolbar's multi-field q covers authors
|
||||||
|
Status: state.Status,
|
||||||
|
}
|
||||||
|
if viewerID, vidErr := uuid.Parse(user.ID); vidErr == nil {
|
||||||
|
opts.ViewerUserID = uuidToPGType(viewerID)
|
||||||
|
}
|
||||||
|
|
||||||
var seriesCards []templates.SeriesCardData
|
var seriesCards []templates.SeriesCardData
|
||||||
totalPages := 1
|
totalPages := 1
|
||||||
|
|
||||||
@@ -162,7 +192,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
} else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] {
|
} else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] {
|
||||||
errorMsg = "Library not accessible"
|
errorMsg = "Library not accessible"
|
||||||
} else {
|
} else {
|
||||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset)
|
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||||
errorMsg = "Error loading series"
|
errorMsg = "Error loading series"
|
||||||
@@ -183,6 +213,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
BookCount: s.BookCount,
|
BookCount: s.BookCount,
|
||||||
TotalInSeries: s.TotalInSeries,
|
TotalInSeries: s.TotalInSeries,
|
||||||
CoverPaths: covers,
|
CoverPaths: covers,
|
||||||
|
ReadCount: s.ReadCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,7 +225,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, errorMsg).Render(c.Request().Context(), &buf)
|
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, state, errorMsg).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1413,6 +1444,67 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Content partial for the retry queue: after the client drains its
|
||||||
|
// sequential retry queue it swaps this in over #issues-content so
|
||||||
|
// resolved issues drop off server-side (unlinked_books matches
|
||||||
|
// partial precedent). Same family-lens gate as the page.
|
||||||
|
frontendProtected.GET("/admin/libraries/:id/issues/content", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||||
|
libraryID := c.Param("id")
|
||||||
|
libUUID, err := uuid.Parse(libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Invalid library ID", "invalid_id")
|
||||||
|
}
|
||||||
|
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
|
||||||
|
if visErr != nil {
|
||||||
|
return renderErrorPage(c, "Error checking library access", "visibility_error")
|
||||||
|
}
|
||||||
|
if !visibleSet[libraryID] {
|
||||||
|
return renderErrorPage(c, "Library not accessible", "library_not_accessible")
|
||||||
|
}
|
||||||
|
stats, err := cfg.Queries.GetProcessingIssueStats(c.Request().Context(), uuidToPGType(libUUID))
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading stats", "stats_load_error")
|
||||||
|
}
|
||||||
|
dbIssues, err := cfg.Queries.ListProcessingIssuesByLibrary(c.Request().Context(), uuidToPGType(libUUID))
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading issues", "issues_load_error")
|
||||||
|
}
|
||||||
|
issues := make([]templates.ProcessingIssueData, len(dbIssues))
|
||||||
|
for i, issue := range dbIssues {
|
||||||
|
issueUUID, _ := uuid.FromBytes(issue.ID.Bytes[0:16])
|
||||||
|
itemID := ""
|
||||||
|
if issue.MediaItemID.Valid {
|
||||||
|
itemID = uuid.UUID(issue.MediaItemID.Bytes).String()
|
||||||
|
}
|
||||||
|
title := issue.Title
|
||||||
|
if title == "" {
|
||||||
|
title = issue.FilePath
|
||||||
|
}
|
||||||
|
issues[i] = templates.ProcessingIssueData{
|
||||||
|
ID: issueUUID.String(),
|
||||||
|
MediaItemID: itemID,
|
||||||
|
Title: title,
|
||||||
|
FilePath: issue.FilePath,
|
||||||
|
FormatGroup: issue.FormatGroup,
|
||||||
|
LibraryTypeName: issue.LibraryTypeName,
|
||||||
|
IssueType: issue.IssueType,
|
||||||
|
IssueDescription: issue.IssueDescription,
|
||||||
|
Severity: issue.Severity,
|
||||||
|
CreatedAt: issue.CreatedAt.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statsData := templates.IssueStats{
|
||||||
|
ErrorCount: stats.ErrorCount,
|
||||||
|
WarningCount: stats.WarningCount,
|
||||||
|
InfoCount: stats.InfoCount,
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.ProcessingIssuesContent(libraryID, issues, statsData).Render(c.Request().Context(), &buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
}))
|
||||||
|
|
||||||
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
||||||
// backfill or rescan, resolved by keeping all copies or merging into one.
|
// backfill or rescan, resolved by keeping all copies or merging into one.
|
||||||
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func registerLibraryRoutes(cfg *Config) {
|
|||||||
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
|
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
|
||||||
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
|
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
|
||||||
adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue)
|
adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue)
|
||||||
|
adminLibrary.POST("/:id/issues/:issueId/retry", cfg.ProcessingIssuesHandler.RetryProcessingIssue)
|
||||||
adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
|
adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
|
||||||
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
|
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
|
||||||
libraryID := c.Param("id")
|
libraryID := c.Param("id")
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func registerMediaRoutes(cfg *Config) {
|
|||||||
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
||||||
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
||||||
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
|
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
|
||||||
|
admin.POST("/media-items/:id/write-metadata", cfg.MediaHandler.WriteMetadataToFile)
|
||||||
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
|
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
|
||||||
admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems)
|
admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems)
|
||||||
admin.POST("/media-items/:id/unarchive", cfg.MediaHandler.UnarchiveMediaItem)
|
admin.POST("/media-items/:id/unarchive", cfg.MediaHandler.UnarchiveMediaItem)
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ import (
|
|||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"log"
|
"log"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -349,6 +353,19 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Bundle stamps: ?v=<content-hash> per bundle, computed at startup —
|
||||||
|
// a rebuild that changes a bundle's bytes changes its cache key with
|
||||||
|
// no manual stamp bump (see templates/bundles.go; the manual ?v=N
|
||||||
|
// stamp is how a fixed search dropdown stayed broken for a deploy).
|
||||||
|
for _, bundle := range []string{"main.js", "reader.js"} {
|
||||||
|
if data, err := os.ReadFile(filepath.Join("web/static", bundle)); err == nil {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
templates.SetBundleVersion(bundle, hex.EncodeToString(sum[:])[:8])
|
||||||
|
} else {
|
||||||
|
log.Printf("bundle %s not found for stamping: %v", bundle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
e.Static("/static", "web/static")
|
e.Static("/static", "web/static")
|
||||||
|
|
||||||
// Catch-all 404 handler - must be last
|
// Catch-all 404 handler - must be last
|
||||||
|
|||||||
@@ -90,9 +90,15 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
|||||||
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
|
||||||
// Render using BooksGrid template (or BookPickerGrid for collection picker)
|
// Render using BooksGrid template (or BookPickerGrid for collection picker)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if c.QueryParam("show_checkbox") == "true" {
|
switch {
|
||||||
|
case c.QueryParam("show_checkbox") == "true":
|
||||||
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
|
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
|
||||||
} else {
|
case c.QueryParam("layout") == "dropdown":
|
||||||
|
// Header-search dropdown: compact templ rows (rendering
|
||||||
|
// ownership) carrying the viewer's progress/completed overlay.
|
||||||
|
// Replaces the old innerHTML string renderer in search.ts.
|
||||||
|
err = templates.SearchResults(bookInfoList, c.QueryParam("q")).Render(c.Request().Context(), &buf)
|
||||||
|
default:
|
||||||
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
|
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -831,18 +831,70 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|||||||
// retention window (ARCHIVE_RETENTION_DAYS; 0 = manual purge only).
|
// retention window (ARCHIVE_RETENTION_DAYS; 0 = manual purge only).
|
||||||
// Every branch logs - the previous hard-delete cleanup failed silently
|
// Every branch logs - the previous hard-delete cleanup failed silently
|
||||||
// and left orphaned rows undetected.
|
// and left orphaned rows undetected.
|
||||||
|
// Group this scan's folders by owning library: the reconciliation is
|
||||||
|
// per LIBRARY, comparing every library item against the union of all
|
||||||
|
// its folders' walks. The old per-folder loop listed the whole
|
||||||
|
// library's items against each single folder's walk, so with two
|
||||||
|
// folders every item living under the other folder was flagged
|
||||||
|
// missing on every scan (prod: 743/743 audiobooks stuck at
|
||||||
|
// "missing (1/2)", unarchivable only because the walk kept clearing
|
||||||
|
// them) — and with three or more folders earlier iterations' marks
|
||||||
|
// crossed later ones' walks and archived real content mid-pass.
|
||||||
|
scannedSet := make(map[string]bool, len(s.folders))
|
||||||
|
for _, scanned := range s.folders {
|
||||||
|
scannedSet[scanned] = true
|
||||||
|
}
|
||||||
|
type archiveScope struct {
|
||||||
|
libraryID pgtype.UUID
|
||||||
|
name string
|
||||||
|
folders []string
|
||||||
|
}
|
||||||
|
scopes := make([]archiveScope, 0, len(s.folders))
|
||||||
|
scopeIndex := make(map[pgtype.UUID]int, len(s.folders))
|
||||||
for _, folder := range s.folders {
|
for _, folder := range s.folders {
|
||||||
|
lib, err := s.db.GetLibraryByFolder(ctx, folder)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[ARCHIVE] Warning: no library found for folder %s, skipping it in archive pass: %v\n", folder, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx, seen := scopeIndex[lib.LibraryID]
|
||||||
|
if !seen {
|
||||||
|
idx = len(scopes)
|
||||||
|
scopeIndex[lib.LibraryID] = idx
|
||||||
|
scopes = append(scopes, archiveScope{libraryID: lib.LibraryID, name: lib.Name})
|
||||||
|
}
|
||||||
|
scopes[idx].folders = append(scopes[idx].folders, folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scope := range scopes {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
fmt.Printf("[ARCHIVE] Scan cancelled, stopping archive pass\n")
|
fmt.Printf("[ARCHIVE] Scan cancelled, stopping archive pass\n")
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
libraryID := scope.libraryID
|
||||||
|
|
||||||
lib, err := s.db.GetLibraryByFolder(ctx, folder)
|
// Partial-scope guard (per-folder scans, 2026-10): the
|
||||||
if err != nil {
|
// reconciliation compares EVERY library item against this scan's
|
||||||
fmt.Printf("[ARCHIVE] Warning: no library found for folder %s, skipping archive pass: %v\n", folder, err)
|
// 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
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
scope.name, covered, len(registeredFolders))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
libraryID := lib.LibraryID
|
|
||||||
|
|
||||||
dbItems, err := s.db.ListMediaItemsByLibraryIncludingArchived(ctx, libraryID)
|
dbItems, err := s.db.ListMediaItemsByLibraryIncludingArchived(ctx, libraryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -850,8 +902,11 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build set of scanned file paths for this folder
|
// Build the set of scanned paths across ALL of this library's
|
||||||
|
// folders.
|
||||||
scannedPaths := make(map[string]bool)
|
scannedPaths := make(map[string]bool)
|
||||||
|
walkFailed := false
|
||||||
|
for _, folder := range scope.folders {
|
||||||
if err := filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error {
|
if err := filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error {
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
return walkErr
|
return walkErr
|
||||||
@@ -862,14 +917,24 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
fmt.Printf("[ARCHIVE] Warning: failed to walk directory %s, skipping archive pass: %v\n", folder, err)
|
fmt.Printf("[ARCHIVE] Warning: failed to walk directory %s, skipping archive pass: %v\n", folder, err)
|
||||||
continue // Skip to next folder to avoid false archivals
|
walkFailed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if walkFailed {
|
||||||
|
continue // Skip this library to avoid false archivals
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folder-book roots count as present: the item's file_path is the
|
// Folder-book roots count as present: the item's file_path is the
|
||||||
// DIRECTORY, which the file walk above never emits.
|
// DIRECTORY, which the file walk above never emits. Attribution is
|
||||||
|
// boundary-safe — a sibling folder whose name merely extends this
|
||||||
|
// one's must not contribute its roots here.
|
||||||
for root := range s.audioBookRoots {
|
for root := range s.audioBookRoots {
|
||||||
if strings.HasPrefix(root, folder) {
|
for _, folder := range scope.folders {
|
||||||
|
if folderContains(folder, root) {
|
||||||
scannedPaths[s.getRelativePath(root)] = true
|
scannedPaths[s.getRelativePath(root)] = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,7 +992,7 @@ func (s *MediaScanner) isScannableFile(path string) bool {
|
|||||||
// Find which folder this file belongs to
|
// Find which folder this file belongs to
|
||||||
var folder string
|
var folder string
|
||||||
for _, f := range s.folders {
|
for _, f := range s.folders {
|
||||||
if strings.HasPrefix(path, f) {
|
if folderContains(f, path) {
|
||||||
folder = f
|
folder = f
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1090,7 +1155,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
|||||||
folderLibrary := s.folderLibrary
|
folderLibrary := s.folderLibrary
|
||||||
s.scanStateMu.RUnlock()
|
s.scanStateMu.RUnlock()
|
||||||
for _, folder := range s.folders {
|
for _, folder := range s.folders {
|
||||||
if !strings.HasPrefix(path, folder) {
|
if !folderContains(folder, path) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if libID, cached := folderLibrary[folder]; cached {
|
if libID, cached := folderLibrary[folder]; cached {
|
||||||
@@ -1233,7 +1298,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
|||||||
// Use the root folder that contains this file
|
// Use the root folder that contains this file
|
||||||
var rootFolder string
|
var rootFolder string
|
||||||
for _, folder := range s.folders {
|
for _, folder := range s.folders {
|
||||||
if strings.HasPrefix(path, folder) {
|
if folderContains(folder, path) {
|
||||||
rootFolder = folder
|
rootFolder = folder
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1577,6 +1642,21 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
|
|||||||
}
|
}
|
||||||
if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
|
if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
|
||||||
fmt.Printf("Warning: failed to update media item: %v\n", err)
|
fmt.Printf("Warning: failed to update media item: %v\n", err)
|
||||||
|
// Ledger (Phase 11): the item exists — record item-backed so
|
||||||
|
// the Processing Issues page flags the degraded metadata and
|
||||||
|
// offers a retry, instead of failing silently on every scan
|
||||||
|
// (the 2026-10 Crusader genre-22001 loop did exactly that).
|
||||||
|
// Same type the sidecar-change path uses; resolveIssuesForItem
|
||||||
|
// clears it when an update finally succeeds.
|
||||||
|
if logErr := s.LogProcessingIssue(ctx,
|
||||||
|
uuid.UUID(existingItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
|
||||||
|
"metadata_extraction_failed",
|
||||||
|
fmt.Sprintf("Metadata update failed; row left unchanged: %v", err),
|
||||||
|
"warning"); logErr != nil {
|
||||||
|
fmt.Printf("Warning: failed to record update issue for %s: %v\n", path, logErr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.resolveIssuesForItem(ctx, existingItem.ID)
|
||||||
}
|
}
|
||||||
// The bytes changed, so any stored hash is stale.
|
// The bytes changed, so any stored hash is stale.
|
||||||
s.recomputeHashInfo(ctx, entry.ID, libraryID, path)
|
s.recomputeHashInfo(ctx, entry.ID, libraryID, path)
|
||||||
@@ -1643,6 +1723,18 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
|
|||||||
}
|
}
|
||||||
if err := s.updateMediaItemWithMetadata(ctx, existingItem, path, metadata); err != nil {
|
if err := s.updateMediaItemWithMetadata(ctx, existingItem, path, metadata); err != nil {
|
||||||
fmt.Printf("Warning: failed to update media item after sidecar change %s: %v\n", path, err)
|
fmt.Printf("Warning: failed to update media item after sidecar change %s: %v\n", path, err)
|
||||||
|
// Ledger: same item-backed record as the size-change path —
|
||||||
|
// a failed re-extraction must be visible and retryable, not a
|
||||||
|
// scan-log whisper.
|
||||||
|
if logErr := s.LogProcessingIssue(ctx,
|
||||||
|
uuid.UUID(existingItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
|
||||||
|
"metadata_extraction_failed",
|
||||||
|
fmt.Sprintf("Metadata update after sidecar change failed; row left unchanged: %v", err),
|
||||||
|
"warning"); logErr != nil {
|
||||||
|
fmt.Printf("Warning: failed to record sidecar-update issue for %s: %v\n", path, logErr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.resolveIssuesForItem(ctx, existingItem.ID)
|
||||||
}
|
}
|
||||||
// Playlist sync on the change path (plan Correction 2): a
|
// Playlist sync on the change path (plan Correction 2): a
|
||||||
// "(Reading Order)" entry added to an EXISTING book's sidecar
|
// "(Reading Order)" entry added to an EXISTING book's sidecar
|
||||||
@@ -1719,24 +1811,25 @@ func applyTitleDerivedSeries(metadata *MediaMetadata) {
|
|||||||
|
|
||||||
// extractCalibreSidecar checks for and parses a Calibre metadata.opf sidecar file
|
// extractCalibreSidecar checks for and parses a Calibre metadata.opf sidecar file
|
||||||
func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
|
func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
|
||||||
// Get directory of media file
|
// Per-file sidecar FIRST (<basename>.opf, the writer's multi-book
|
||||||
|
// safe name), then Calibre's dir-scoped metadata.opf.
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
opfPath := filepath.Join(dir, "metadata.opf")
|
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||||
|
for _, name := range []string{base + ".opf", "metadata.opf"} {
|
||||||
// Check if sidecar exists
|
opfPath := filepath.Join(dir, name)
|
||||||
if _, err := os.Stat(opfPath); os.IsNotExist(err) {
|
if _, err := os.Stat(opfPath); err != nil {
|
||||||
return nil // No sidecar, not an error
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse sidecar
|
// Parse sidecar
|
||||||
metadata, err := s.parseCalibreMetadataOPF(opfPath)
|
metadata, err := s.parseCalibreMetadataOPF(opfPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Warning: failed to parse Calibre metadata.opf: %v\n", err)
|
fmt.Printf("Warning: failed to parse Calibre metadata.opf: %v\n", err)
|
||||||
return nil // Parsing failed, fall back to embedded
|
continue // fall back to the next candidate / embedded
|
||||||
}
|
}
|
||||||
|
|
||||||
return metadata
|
return metadata
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// applyEmbeddedCoverFallback extracts a cover from the media file itself when
|
// applyEmbeddedCoverFallback extracts a cover from the media file itself when
|
||||||
// a metadata sidecar (metadata.opf / metadata.json) supplied the metadata but
|
// a metadata sidecar (metadata.opf / metadata.json) supplied the metadata but
|
||||||
@@ -3997,7 +4090,7 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
|
|||||||
// could clear series/author that a sidecar didn't carry (Libation
|
// could clear series/author that a sidecar didn't carry (Libation
|
||||||
// writes "series": null while the folder layout knows the series).
|
// writes "series": null while the folder layout knows the series).
|
||||||
for _, folder := range s.folders {
|
for _, folder := range s.folders {
|
||||||
if strings.HasPrefix(path, folder) {
|
if folderContains(folder, path) {
|
||||||
if folderMetadata := s.extractFolderStructureMetadata(path, folder); folderMetadata != nil {
|
if folderMetadata := s.extractFolderStructureMetadata(path, folder); folderMetadata != nil {
|
||||||
if metadata.Title == "" && folderMetadata.Title != "" {
|
if metadata.Title == "" && folderMetadata.Title != "" {
|
||||||
metadata.Title = folderMetadata.Title
|
metadata.Title = folderMetadata.Title
|
||||||
@@ -5006,13 +5099,27 @@ func (s *MediaScanner) extractHashInfo(filePath string) (*HashInfo, *FormatInfo,
|
|||||||
return hashInfo, formatInfo, nil
|
return hashInfo, formatInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// folderContains reports whether path is folder itself or lies beneath it
|
||||||
|
// at a directory boundary. This is the only path-correct containment test:
|
||||||
|
// a bare strings.HasPrefix(path, folder) also matches string-prefix
|
||||||
|
// siblings ("/app/audiobooks" claims "/app/audiobookssd/..."), which once
|
||||||
|
// sliced a stray "sd/" into every stored relative path for that library
|
||||||
|
// and resolved those files against the wrong root (folder ownership,
|
||||||
|
// library attribution, extension allowlisting, cover 404s).
|
||||||
|
func folderContains(folder, path string) bool {
|
||||||
|
folder = filepath.Clean(folder)
|
||||||
|
return path == folder || strings.HasPrefix(path, folder+string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) getRelativePath(absolutePath string) string {
|
func (s *MediaScanner) getRelativePath(absolutePath string) string {
|
||||||
// Get the base folder paths from scanner
|
// Get the base folder paths from scanner
|
||||||
for _, baseFolder := range s.folders {
|
for _, baseFolder := range s.folders {
|
||||||
// Check if path is within this base folder
|
// Check if path is within this base folder — boundary-aware, so a
|
||||||
if relPath, ok := strings.CutPrefix(absolutePath, baseFolder); ok {
|
// sibling whose name merely extends ours can never be cut.
|
||||||
return strings.TrimPrefix(relPath, "/")
|
if !folderContains(baseFolder, absolutePath) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
return strings.TrimPrefix(absolutePath[len(filepath.Clean(baseFolder)):], "/")
|
||||||
}
|
}
|
||||||
// Fallback: if no match, return as-is (shouldn't happen)
|
// Fallback: if no match, return as-is (shouldn't happen)
|
||||||
return absolutePath
|
return absolutePath
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AudioChapter is the uniform chapter entry for every audiobook file model.
|
// AudioChapter is the uniform chapter entry for every audiobook file model.
|
||||||
@@ -394,7 +396,8 @@ func marshalAudioMetadata(payload *AudioMetadataPayload) []byte {
|
|||||||
// ----:com.apple.iTunes:SUBTITLE → joined into the title
|
// ----:com.apple.iTunes:SUBTITLE → joined into the title
|
||||||
// ("Title: Subtitle"), mirroring the ABS sidecar path
|
// ("Title: Subtitle"), mirroring the ABS sidecar path
|
||||||
// ----:com.apple.iTunes:ASIN / ABRIDGED → asin / abridged flag
|
// ----:com.apple.iTunes:ASIN / ABRIDGED → asin / abridged flag
|
||||||
// date → publish date; genre → genre
|
// date → publish date; genre → smart-genre (shelf-or-empty column,
|
||||||
|
// split candidates to tags — see pickSmartGenre)
|
||||||
//
|
//
|
||||||
// Abridged is the one field where an explicit sidecar false must not be
|
// Abridged is the one field where an explicit sidecar false must not be
|
||||||
// overridden by a tag — the payload pointer marks sidecar presence.
|
// overridden by a tag — the payload pointer marks sidecar presence.
|
||||||
@@ -472,7 +475,21 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if metadata.Genre == "" {
|
if metadata.Genre == "" {
|
||||||
metadata.Genre = report.tag("genre")
|
// Same doctrine as the ABS sidecar path (ab2a5568): the raw file
|
||||||
|
// atom is a comma-joined category blob ("Politicians, Presidents &
|
||||||
|
// Heads of State, United States, ..." — Audible ladders, often past
|
||||||
|
// 100 chars). Shelf-or-empty wins the genre column; every split
|
||||||
|
// candidate lands in tags. No shelf match → the column stays empty
|
||||||
|
// and the blob is tags-only, never a VARCHAR-overflowing INSERT.
|
||||||
|
if genreTag := report.tag("genre"); genreTag != "" {
|
||||||
|
genre, all := pickSmartGenre([]string{genreTag})
|
||||||
|
if genre != "" {
|
||||||
|
metadata.Genre = genre
|
||||||
|
}
|
||||||
|
if len(all) > 0 {
|
||||||
|
metadata.Tags = utils.NormalizeTags(append(metadata.Tags, all...))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package services
|
|||||||
// and tag-based track ordering.
|
// and tag-based track ordering.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -178,8 +179,14 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
|
|||||||
if meta.PublishDate.Year() != 2020 {
|
if meta.PublishDate.Year() != 2020 {
|
||||||
t.Fatalf("publish date = %v", meta.PublishDate)
|
t.Fatalf("publish date = %v", meta.PublishDate)
|
||||||
}
|
}
|
||||||
if meta.Genre != "Audiobook" {
|
// Smart-genre routing (2026-10 Crusader fix): "Audiobook" is a
|
||||||
t.Fatalf("genre = %q", meta.Genre)
|
// DEMOTED genre (genreDemoted) — it is tags-only and never wins
|
||||||
|
// the column, mirroring the sidecar path's pickSmartGenre.
|
||||||
|
if meta.Genre != "" {
|
||||||
|
t.Fatalf("demoted genre must not win the column, got %q", meta.Genre)
|
||||||
|
}
|
||||||
|
if !slices.Contains(meta.Tags, "Audiobook") {
|
||||||
|
t.Fatalf("demoted genre must stay in tags, got %v", meta.Tags)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -214,6 +221,46 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
|
|||||||
t.Fatal("nil report must change nothing")
|
t.Fatal("nil report must change nothing")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("audible genre blob splits into tags, column stays empty when no shelf matches", func(t *testing.T) {
|
||||||
|
// The 2026-10 Crusader regression: Libation writes the Audible
|
||||||
|
// category ladder as one comma-joined atom — 111 chars here — and
|
||||||
|
// the fallback used to capture it VERBATIM into media_items.genre,
|
||||||
|
// failing the import/update with SQLSTATE 22001 (VARCHAR(100)).
|
||||||
|
// The genre column takes a known shelf or stays empty; the split
|
||||||
|
// candidates are tags-only, exactly like the sidecar path.
|
||||||
|
crusader := "Politicians, Presidents & Heads of State, United States, Russia, Communism & Socialism, International Relations"
|
||||||
|
if len(crusader) <= 100 {
|
||||||
|
t.Fatalf("fixture must exceed the old VARCHAR(100) limit, len=%d", len(crusader))
|
||||||
|
}
|
||||||
|
meta := &MediaMetadata{}
|
||||||
|
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{"genre": crusader}))
|
||||||
|
if len(meta.Genre) > 100 {
|
||||||
|
t.Fatalf("genre column value must never exceed 100 chars, got %d: %q", len(meta.Genre), meta.Genre)
|
||||||
|
}
|
||||||
|
if meta.Genre != "" {
|
||||||
|
t.Fatalf("no candidate is a known shelf — column must stay empty, got %q", meta.Genre)
|
||||||
|
}
|
||||||
|
// NormalizeTags titlecases: "of" → "Of" is the shipped display form.
|
||||||
|
for _, want := range []string{"Politicians", "Presidents & Heads Of State", "United States", "Russia", "Communism & Socialism", "International Relations"} {
|
||||||
|
if !slices.Contains(meta.Tags, want) {
|
||||||
|
t.Fatalf("tags must contain split candidate %q, got %v", want, meta.Tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("known shelf wins the column from a genre blob", func(t *testing.T) {
|
||||||
|
meta := &MediaMetadata{}
|
||||||
|
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
|
||||||
|
"genre": "Epic, Action & Adventure, Military, Sword & Sorcery",
|
||||||
|
}))
|
||||||
|
if meta.Genre == "" {
|
||||||
|
t.Fatalf("expected a lexicon shelf from the blob, tags=%v", meta.Tags)
|
||||||
|
}
|
||||||
|
if !slices.Contains(meta.Tags, "Epic") || !slices.Contains(meta.Tags, "Military") {
|
||||||
|
t.Fatalf("split candidates must land in tags, got %v", meta.Tags)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseTagOrdinal(t *testing.T) {
|
func TestParseTagOrdinal(t *testing.T) {
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ func (s *MediaScanner) maybeProcessAudioBook(ctx context.Context, path string) (
|
|||||||
// so any scannable audio file belongs to one.
|
// so any scannable audio file belongs to one.
|
||||||
rootFolder := ""
|
rootFolder := ""
|
||||||
for _, folder := range s.folders {
|
for _, folder := range s.folders {
|
||||||
if strings.HasPrefix(path, folder) {
|
if folderContains(folder, path) {
|
||||||
rootFolder = folder
|
rootFolder = folder
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -731,7 +731,7 @@ func (s *MediaScanner) rescanMultiFileAudioBook(ctx context.Context, item databa
|
|||||||
|
|
||||||
rootFolder := ""
|
rootFolder := ""
|
||||||
for _, folder := range s.folders {
|
for _, folder := range s.folders {
|
||||||
if strings.HasPrefix(fullPath, folder) {
|
if folderContains(folder, fullPath) {
|
||||||
rootFolder = folder
|
rootFolder = folder
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Regression tests for directory-boundary folder matching.
|
||||||
|
//
|
||||||
|
// getRelativePath (and the folder-membership checks across the scanner)
|
||||||
|
// once used bare string-prefix matching, so a registered folder whose path
|
||||||
|
// was a string prefix of a sibling's ("/app/audiobooks" vs
|
||||||
|
// "/app/audiobookssd") claimed the sibling's files: relative paths were
|
||||||
|
// cut mid-component into "sd/..."-style garbage, stored that way, and
|
||||||
|
// resolved against the wrong root — covers 404ing while the bytes sat one
|
||||||
|
// directory away. folderContains pins the rule: a folder only ever claims
|
||||||
|
// itself or paths beneath it at a real separator boundary, regardless of
|
||||||
|
// names, registration order, or trailing slashes.
|
||||||
|
|
||||||
|
func TestFolderContains(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
folder string
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"exact root", "/app/books", "/app/books", true},
|
||||||
|
{"direct child", "/app/books", "/app/books/x.epub", true},
|
||||||
|
{"deep child", "/app/books", "/app/books/a/b/c.epub", true},
|
||||||
|
{"sibling extension", "/app/books", "/app/books2", false},
|
||||||
|
{"sibling extension child", "/app/books", "/app/books2/x.epub", false},
|
||||||
|
{"mid-word sibling", "/data/lib", "/data/library/x.epub", false},
|
||||||
|
{"suffix without separator", "/app/books", "/app/books-backup/x.epub", false},
|
||||||
|
{"unrelated", "/app/books", "/opt/other/x.epub", false},
|
||||||
|
{"prefix inside path segment", "/app/books", "/app/series/books/x.epub", false},
|
||||||
|
{"trailing slash in folder", "/app/books/", "/app/books/x.epub", true},
|
||||||
|
{"trailing slash exact", "/app/books/", "/app/books", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := folderContains(tt.folder, tt.path); got != tt.want {
|
||||||
|
t.Errorf("folderContains(%q, %q) = %v, want %v", tt.folder, tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRelativePathFolderBoundary(t *testing.T) {
|
||||||
|
// The prod trap: /app/audiobooks registered before /app/audiobookssd.
|
||||||
|
// String-prefix cutting produced "sd/Brian Jacques/..." for every file
|
||||||
|
// in the second folder; the boundary rule must make that impossible.
|
||||||
|
newScanner := func(folders []string) *MediaScanner {
|
||||||
|
s := NewMediaScanner(nil)
|
||||||
|
s.folders = folders // SetFolders requires a live DB; tests only need path relativization
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
siblingFolders := []string{"/app/audiobooks", "/app/audiobookssd"}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
folders []string
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "sibling-prefix folder keeps its full name in relative path",
|
||||||
|
folders: siblingFolders,
|
||||||
|
path: "/app/audiobookssd/Brian Jacques/Redwall/Book 01 - Redwall/file.m4b",
|
||||||
|
want: "Brian Jacques/Redwall/Book 01 - Redwall/file.m4b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "first registered folder unaffected",
|
||||||
|
folders: siblingFolders,
|
||||||
|
path: "/app/audiobooks/Author/Series/file.mp3",
|
||||||
|
want: "Author/Series/file.mp3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact root yields empty path",
|
||||||
|
folders: siblingFolders,
|
||||||
|
path: "/app/audiobookssd",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "order independence: sibling registered first",
|
||||||
|
folders: []string{"/app/audiobookssd", "/app/audiobooks"},
|
||||||
|
path: "/app/audiobookssd/Brian Jacques/Redwall/Book 01 - Redwall/file.m4b",
|
||||||
|
want: "Brian Jacques/Redwall/Book 01 - Redwall/file.m4b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "order independence: first folder still correct when second registered first",
|
||||||
|
folders: []string{"/app/audiobookssd", "/app/audiobooks"},
|
||||||
|
path: "/app/audiobooks/Author/Series/file.mp3",
|
||||||
|
want: "Author/Series/file.mp3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing slash on registered folders",
|
||||||
|
folders: []string{"/app/audiobooks/", "/app/audiobookssd/"},
|
||||||
|
path: "/app/audiobookssd/Brian Jacques/file.m4b",
|
||||||
|
want: "Brian Jacques/file.m4b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mid-word sibling folders",
|
||||||
|
folders: []string{"/data/lib", "/data/library"},
|
||||||
|
path: "/data/library/author/book.epub",
|
||||||
|
want: "author/book.epub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "author/series/title layout preserved under true root",
|
||||||
|
folders: siblingFolders,
|
||||||
|
path: "/app/audiobookssd/Brian Jacques/Redwall/Book 14 - The Taggerung/Taggerung.mp3",
|
||||||
|
want: "Brian Jacques/Redwall/Book 14 - The Taggerung/Taggerung.mp3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path outside every folder returned as-is",
|
||||||
|
folders: siblingFolders,
|
||||||
|
path: "/app/audiobooksBackup/x.mp3",
|
||||||
|
want: "/app/audiobooksBackup/x.mp3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrelated path returned as-is",
|
||||||
|
folders: siblingFolders,
|
||||||
|
path: "/elsewhere/x.mp3",
|
||||||
|
want: "/elsewhere/x.mp3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := newScanner(tt.folders)
|
||||||
|
if got := s.getRelativePath(tt.path); got != tt.want {
|
||||||
|
t.Errorf("getRelativePath(%q) with folders %v = %q, want %q", tt.path, tt.folders, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,939 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MetadataWriter persists the item's CURRENT database metadata back into
|
||||||
|
// the artifact on disk, so the library stays portable (Calibre, phone
|
||||||
|
// players, other servers). 2026-10-27 feature (user request): embed into
|
||||||
|
// formats that carry metadata, write a Calibre-style sidecar for those
|
||||||
|
// that don't.
|
||||||
|
//
|
||||||
|
// Safety contract:
|
||||||
|
// - temp file + atomic rename; the original is NEVER truncated on
|
||||||
|
// failure (a failed write must not destroy a book)
|
||||||
|
// - the write mirrors what the scanner's read chain parses
|
||||||
|
// (parseOPFContent), so a write + rescan round-trips
|
||||||
|
// - metadata_overrides are left untouched: after a successful write
|
||||||
|
// the file and the DB agree, and the overrides keep stale sidecars
|
||||||
|
// from clobbering later rescans
|
||||||
|
//
|
||||||
|
// Consequences that are inherent to editing files in place (documented
|
||||||
|
// in docs/user/calibre-integration.md): the mtime change makes the
|
||||||
|
// watcher re-extract (wanted), and the SHA-256 changes (dedup/move
|
||||||
|
// detection re-keys; Kobo device sidecars re-key on next sync).
|
||||||
|
|
||||||
|
// MetadataWriteTarget names where a format's metadata landed.
|
||||||
|
type MetadataWriteTarget string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MetadataTargetEmbeddedOPF: the OPF inside the EPUB was rewritten.
|
||||||
|
MetadataTargetEmbeddedOPF MetadataWriteTarget = "embedded-opf"
|
||||||
|
// MetadataTargetSidecarOPF: a Calibre-style sidecar was written next
|
||||||
|
// to the file (formats that can't carry metadata: PDF, comics,
|
||||||
|
// folder books).
|
||||||
|
MetadataTargetSidecarOPF MetadataWriteTarget = "sidecar-opf"
|
||||||
|
// MetadataTargetAudioTags: the container's own tags were rewritten
|
||||||
|
// via ffmpeg stream-copy (single-file MP3/FLAC/M4A/M4B — 5b).
|
||||||
|
MetadataTargetAudioTags MetadataWriteTarget = "audio-tags"
|
||||||
|
// MetadataTargetComicInfo: ComicInfo.xml inside the CBZ was
|
||||||
|
// injected/replaced (5c). CBR/CB7 stay on the sidecar path — RAR
|
||||||
|
// and 7z archives can't be rewritten in place.
|
||||||
|
MetadataTargetComicInfo MetadataWriteTarget = "comicinfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// audioTagExts is the single-file set the tag writer claims; multi-file
|
||||||
|
// (folder) audio stays on the sidecar path.
|
||||||
|
var audioTagExts = map[string]bool{
|
||||||
|
".mp3": true, ".flac": true, ".m4a": true, ".m4b": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataWriteResult reports what the write did, for the UI toast and
|
||||||
|
// the response contract.
|
||||||
|
type MetadataWriteResult struct {
|
||||||
|
Target MetadataWriteTarget `json:"target"`
|
||||||
|
Path string `json:"path"` // absolute path of the artifact written
|
||||||
|
Sidecar bool `json:"sidecar"`
|
||||||
|
FieldsWritten []string `json:"fields_written"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataWriteTargetFor maps a file extension to the write target.
|
||||||
|
// The UI mirrors this to label the button; keep both in lockstep.
|
||||||
|
func MetadataWriteTargetFor(ext string) MetadataWriteTarget {
|
||||||
|
ext = strings.ToLower(strings.TrimSpace(ext))
|
||||||
|
switch {
|
||||||
|
case ext == ".epub" || ext == ".kepub":
|
||||||
|
return MetadataTargetEmbeddedOPF
|
||||||
|
case audioTagExts[ext]:
|
||||||
|
return MetadataTargetAudioTags
|
||||||
|
case ext == ".cbz":
|
||||||
|
return MetadataTargetComicInfo
|
||||||
|
default:
|
||||||
|
// CBR/CB7 (no in-place rewrite) and every other format take
|
||||||
|
// the sidecar.
|
||||||
|
return MetadataTargetSidecarOPF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataWriter carries the dependencies for file-writing operations.
|
||||||
|
type MetadataWriter struct {
|
||||||
|
db *database.Queries
|
||||||
|
libraries *LibraryService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMetadataWriter(db *database.Queries) *MetadataWriter {
|
||||||
|
return &MetadataWriter{db: db, libraries: NewLibraryService(db)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteMetadataToFile dispatches on the item's format and persists the
|
||||||
|
// current DB values into the artifact on disk.
|
||||||
|
func (w *MetadataWriter) WriteMetadataToFile(ctx context.Context, itemID uuid.UUID) (*MetadataWriteResult, error) {
|
||||||
|
item, err := w.db.GetMediaItem(ctx, pgtype.UUID{Bytes: itemID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("media item not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := w.libraries.ResolveMediaPath(ctx, item.LibraryID, item.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot resolve library folder: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(absPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("media file missing on disk: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch MetadataWriteTargetFor(filepath.Ext(absPath)) {
|
||||||
|
case MetadataTargetEmbeddedOPF:
|
||||||
|
path, err := w.writeEPUBMetadata(absPath, &item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &MetadataWriteResult{
|
||||||
|
Target: MetadataTargetEmbeddedOPF,
|
||||||
|
Path: path,
|
||||||
|
FieldsWritten: metadataFieldNames(&item),
|
||||||
|
}, nil
|
||||||
|
case MetadataTargetAudioTags:
|
||||||
|
path, err := w.writeAudioTags(absPath, &item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &MetadataWriteResult{
|
||||||
|
Target: MetadataTargetAudioTags,
|
||||||
|
Path: path,
|
||||||
|
FieldsWritten: audioFieldNames(&item, filepath.Ext(absPath)),
|
||||||
|
}, nil
|
||||||
|
case MetadataTargetComicInfo:
|
||||||
|
path, err := w.writeCBZComicInfo(absPath, &item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &MetadataWriteResult{
|
||||||
|
Target: MetadataTargetComicInfo,
|
||||||
|
Path: path,
|
||||||
|
FieldsWritten: comicFieldNames(&item),
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
path, err := w.writeSidecarOPF(ctx, absPath, &item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &MetadataWriteResult{
|
||||||
|
Target: MetadataTargetSidecarOPF,
|
||||||
|
Path: path,
|
||||||
|
Sidecar: true,
|
||||||
|
FieldsWritten: metadataFieldNames(&item),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSidecarOPF writes a Calibre-style OPF next to the media file.
|
||||||
|
// Naming: `metadata.opf` (Calibre's own convention) when the item is
|
||||||
|
// the only book in its directory; otherwise `<basename>.opf` so one
|
||||||
|
// book's sidecar can never poison its dir-mates' scans. The scanner
|
||||||
|
// reads both (basename first).
|
||||||
|
func (w *MetadataWriter) writeSidecarOPF(ctx context.Context, absPath string, item *database.MediaItems) (string, error) {
|
||||||
|
dir := filepath.Dir(absPath)
|
||||||
|
base := strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath))
|
||||||
|
|
||||||
|
others, err := w.countSiblingBooks(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot check sibling books: %w", err)
|
||||||
|
}
|
||||||
|
name := "metadata.opf"
|
||||||
|
if others > 0 {
|
||||||
|
name = base + ".opf"
|
||||||
|
}
|
||||||
|
sidecarPath := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
doc := buildSidecarOPFXML(item)
|
||||||
|
if err := atomicWrite(sidecarPath, []byte(doc)); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write sidecar: %w", err)
|
||||||
|
}
|
||||||
|
return sidecarPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countSiblingBooks counts OTHER non-archived items that live in the
|
||||||
|
// same directory of the same library — the guard against writing a
|
||||||
|
// dir-scoped metadata.opf over a multi-book folder.
|
||||||
|
func (w *MetadataWriter) countSiblingBooks(ctx context.Context, item *database.MediaItems) (int, error) {
|
||||||
|
dir := filepath.ToSlash(filepath.Dir(item.FilePath))
|
||||||
|
siblings, err := w.db.CountOtherMediaItemsInDir(ctx, database.CountOtherMediaItemsInDirParams{
|
||||||
|
LibraryID: item.LibraryID,
|
||||||
|
ID: item.ID,
|
||||||
|
DirPrefix: pgtype.Text{String: dir + "/", Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return int(siblings), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeEPUBMetadata splices a freshly generated <metadata> block into
|
||||||
|
// the EPUB's existing OPF (manifest/spine/guide preserved untouched)
|
||||||
|
// and rewrites the zip atomically: every other entry is copied
|
||||||
|
// byte-identical, in original order with original compression — the
|
||||||
|
// mimetype entry must stay first and STORED.
|
||||||
|
func (w *MetadataWriter) writeEPUBMetadata(absPath string, item *database.MediaItems) (string, error) {
|
||||||
|
reader, err := zip.OpenReader(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open EPUB: %w", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
opfName := findOPFPathInZip(reader.File)
|
||||||
|
if opfName == "" {
|
||||||
|
return "", fmt.Errorf("EPUB has no OPF (container.xml missing rootfile)")
|
||||||
|
}
|
||||||
|
|
||||||
|
newOPF, err := spliceOPFMetadata(reader.File, opfName, item)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := absPath + ".tmp-write"
|
||||||
|
if err := rewriteEPUBZip(reader.File, opfName, newOPF, tmpPath); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, absPath); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", fmt.Errorf("failed to replace EPUB: %w", err)
|
||||||
|
}
|
||||||
|
return absPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// spliceOPFMetadata returns the OPF with its <metadata> inner XML
|
||||||
|
// replaced. The ORIGINAL opening tag (with its namespaces and
|
||||||
|
// unique-identifier) is preserved so manifest/spine id references keep
|
||||||
|
// resolving.
|
||||||
|
func spliceOPFMetadata(entries []*zip.File, opfName string, item *database.MediaItems) ([]byte, error) {
|
||||||
|
var opfEntry *zip.File
|
||||||
|
for _, f := range entries {
|
||||||
|
if f.Name == opfName {
|
||||||
|
opfEntry = f
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opfEntry == nil {
|
||||||
|
return nil, fmt.Errorf("OPF %q not found in EPUB", opfName)
|
||||||
|
}
|
||||||
|
rc, err := opfEntry.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open OPF: %w", err)
|
||||||
|
}
|
||||||
|
original, err := io.ReadAll(rc)
|
||||||
|
closeErr := rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read OPF: %w", err)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to close OPF: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
metaStart := bytes.Index(original, []byte("<metadata"))
|
||||||
|
if metaStart == -1 {
|
||||||
|
return nil, fmt.Errorf("OPF has no <metadata> element")
|
||||||
|
}
|
||||||
|
openEnd := bytes.Index(original[metaStart:], []byte(">"))
|
||||||
|
if openEnd == -1 {
|
||||||
|
return nil, fmt.Errorf("OPF <metadata> tag malformed")
|
||||||
|
}
|
||||||
|
metaClose := bytes.Index(original[metaStart:], []byte("</metadata>"))
|
||||||
|
if metaClose == -1 {
|
||||||
|
return nil, fmt.Errorf("OPF has no </metadata> close")
|
||||||
|
}
|
||||||
|
|
||||||
|
openTag := original[metaStart : metaStart+openEnd+1]
|
||||||
|
inner := buildOPFMetadataInner(item) + originalIdentifiers(original[metaStart:metaStart+metaClose])
|
||||||
|
|
||||||
|
out := bytes.NewBuffer(nil)
|
||||||
|
out.Write(original[:metaStart])
|
||||||
|
out.Write(openTag)
|
||||||
|
out.WriteString("\n")
|
||||||
|
out.WriteString(inner)
|
||||||
|
out.Write(original[metaStart+metaClose:])
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// originalIdentifiers extracts the old metadata's dc:identifier
|
||||||
|
// elements verbatim. The package's unique-identifier attribute
|
||||||
|
// references one of them, so replacing the metadata block must keep
|
||||||
|
// them (generated fields come FIRST, so the parser's first-wins reads
|
||||||
|
// the fresh ISBN; the originals remain for identifier stability).
|
||||||
|
func originalIdentifiers(oldMetadata []byte) string {
|
||||||
|
var b strings.Builder
|
||||||
|
rest := oldMetadata
|
||||||
|
for {
|
||||||
|
start := bytes.Index(rest, []byte("<dc:identifier"))
|
||||||
|
if start == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := bytes.Index(rest[start:], []byte("</dc:identifier>"))
|
||||||
|
if end == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end += start + len("</dc:identifier>")
|
||||||
|
b.Write(rest[start:end])
|
||||||
|
b.WriteString("\n")
|
||||||
|
rest = rest[end:]
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteEPUBZip copies every entry to dst, substituting opfName's
|
||||||
|
// content. Order, compression method and modes are preserved.
|
||||||
|
func rewriteEPUBZip(entries []*zip.File, opfName string, newOPF []byte, dst string) error {
|
||||||
|
return rewriteZipArchive(entries, opfName, newOPF, dst, "", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteZipArchive is the generic form: substitute name's content, and
|
||||||
|
// (for the CBZ path, where ComicInfo.xml may not exist yet) append
|
||||||
|
// extraName/extraContent as a final entry when the archive lacks the
|
||||||
|
// substituted name.
|
||||||
|
func rewriteZipArchive(entries []*zip.File, name string, content []byte, dst string, extraName string, extraContent []byte) error {
|
||||||
|
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp archive: %w", err)
|
||||||
|
}
|
||||||
|
writer := zip.NewWriter(out)
|
||||||
|
substituted := false
|
||||||
|
for _, f := range entries {
|
||||||
|
header := f.FileHeader
|
||||||
|
header.Flags &= ^uint16(0x8) // drop data-descriptor flag; sizes are known upfront
|
||||||
|
var target io.Writer
|
||||||
|
if f.Name == name {
|
||||||
|
// Sizes/CRC recomputed by the writer for the new content.
|
||||||
|
header.CompressedSize64 = 0
|
||||||
|
header.UncompressedSize64 = 0
|
||||||
|
header.CompressedSize = 0
|
||||||
|
header.UncompressedSize = 0
|
||||||
|
header.CRC32 = 0
|
||||||
|
target, err = writer.CreateHeader(&header)
|
||||||
|
if err != nil {
|
||||||
|
writer.Close()
|
||||||
|
out.Close()
|
||||||
|
return fmt.Errorf("failed to stage substituted entry: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := target.Write(content); err != nil {
|
||||||
|
writer.Close()
|
||||||
|
out.Close()
|
||||||
|
return fmt.Errorf("failed to write substituted entry: %w", err)
|
||||||
|
}
|
||||||
|
substituted = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target, err = writer.CreateHeader(&header)
|
||||||
|
if err != nil {
|
||||||
|
writer.Close()
|
||||||
|
out.Close()
|
||||||
|
return fmt.Errorf("failed to stage entry %s: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
rc, openErr := f.Open()
|
||||||
|
if openErr != nil {
|
||||||
|
writer.Close()
|
||||||
|
out.Close()
|
||||||
|
return fmt.Errorf("failed to read entry %s: %w", f.Name, openErr)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(target, rc); err != nil {
|
||||||
|
rc.Close()
|
||||||
|
writer.Close()
|
||||||
|
out.Close()
|
||||||
|
return fmt.Errorf("failed to copy entry %s: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
if err := rc.Close(); err != nil {
|
||||||
|
writer.Close()
|
||||||
|
out.Close()
|
||||||
|
return fmt.Errorf("failed to finish entry %s: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !substituted && extraName != "" && extraContent != nil {
|
||||||
|
target, cerr := writer.Create(extraName)
|
||||||
|
if cerr != nil {
|
||||||
|
writer.Close()
|
||||||
|
out.Close()
|
||||||
|
return fmt.Errorf("failed to append entry %s: %w", extraName, cerr)
|
||||||
|
}
|
||||||
|
if _, werr := target.Write(extraContent); werr != nil {
|
||||||
|
writer.Close()
|
||||||
|
out.Close()
|
||||||
|
return fmt.Errorf("failed to write appended entry %s: %w", extraName, werr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
out.Close()
|
||||||
|
return fmt.Errorf("failed to finalize archive: %w", err)
|
||||||
|
}
|
||||||
|
if err := out.Sync(); err != nil {
|
||||||
|
out.Close()
|
||||||
|
return fmt.Errorf("failed to sync archive: %w", err)
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// atomicWrite writes via temp file + rename in the SAME directory so a
|
||||||
|
// crash or a full disk can never leave a truncated book (or sidecar)
|
||||||
|
// behind.
|
||||||
|
func atomicWrite(path string, data []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
tmp, err := os.CreateTemp(dir, ".bookhoard-write-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
// 0644: sidecars/rewritten files must be readable by host-side
|
||||||
|
// tools sharing the volume (Calibre reads metadata.opf); a 0600
|
||||||
|
// temp default would lock everyone but the server out.
|
||||||
|
if err := tmp.Chmod(0o644); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, path); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- audio tags (5b): ffmpeg stream-copy rewrite ----
|
||||||
|
|
||||||
|
// audioFieldNames lists the fields the audio tag path round-trips —
|
||||||
|
// exactly the keys applyAudioTagFallbacks reads back (the ingestion
|
||||||
|
// chain's tags rung). Fields the file format can't carry per our own
|
||||||
|
// read chain (description, publisher, language, isbn, the full tag
|
||||||
|
// list) are deliberately NOT written: "applicable and correct" means
|
||||||
|
// nothing lands in a file the scanner would never find again.
|
||||||
|
// Subtitle rides MP3/FLAC only (the mp4 muxer in our image drops
|
||||||
|
// subtitle keys).
|
||||||
|
func audioFieldNames(item *database.MediaItems, ext string) []string {
|
||||||
|
fields := []string{"title"}
|
||||||
|
if item.Subtitle.Valid && item.Subtitle.String != "" &&
|
||||||
|
!strings.EqualFold(ext, ".m4a") && !strings.EqualFold(ext, ".m4b") {
|
||||||
|
fields = append(fields, "subtitle")
|
||||||
|
}
|
||||||
|
if len(item.Authors) > 0 || (item.Author.Valid && item.Author.String != "") {
|
||||||
|
fields = append(fields, "authors")
|
||||||
|
}
|
||||||
|
if len(item.Narrators) > 0 {
|
||||||
|
fields = append(fields, "narrators")
|
||||||
|
}
|
||||||
|
if item.Series.Valid && item.Series.String != "" {
|
||||||
|
fields = append(fields, "series")
|
||||||
|
}
|
||||||
|
if item.DatePublished.Valid {
|
||||||
|
fields = append(fields, "date_published")
|
||||||
|
}
|
||||||
|
if len(item.Tags) > 0 {
|
||||||
|
fields = append(fields, "genre")
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioTagArgs maps the item's current values onto the container tag
|
||||||
|
// keys the scanner's own fallback chain reads (media_scanner_audio.go):
|
||||||
|
//
|
||||||
|
// title → title + album (players want title; the scanner's
|
||||||
|
// album rung names the book)
|
||||||
|
// subtitle → subtitle (MP3/FLAC only — the mp4 muxer in
|
||||||
|
// our image drops subtitle keys, and
|
||||||
|
// fields we can't read back don't
|
||||||
|
// get written)
|
||||||
|
// authors → artist (first) + album_artist (joined)
|
||||||
|
// narrators → composer (Audible's convention)
|
||||||
|
// series → grouping (series-part rides the iTunes atom)
|
||||||
|
// date → date; first tag → genre
|
||||||
|
func audioTagArgs(item *database.MediaItems, ext string) [][2]string {
|
||||||
|
var args [][2]string
|
||||||
|
add := func(k, v string) {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
args = append(args, [2]string{k, v})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title := strings.TrimSpace(item.Title)
|
||||||
|
add("title", title)
|
||||||
|
add("album", title)
|
||||||
|
if item.Subtitle.Valid && !strings.EqualFold(ext, ".m4a") && !strings.EqualFold(ext, ".m4b") {
|
||||||
|
add("subtitle", strings.TrimSpace(item.Subtitle.String))
|
||||||
|
}
|
||||||
|
|
||||||
|
authors := item.Authors
|
||||||
|
if len(authors) == 0 && item.Author.Valid {
|
||||||
|
authors = []string{item.Author.String}
|
||||||
|
}
|
||||||
|
if len(authors) > 0 {
|
||||||
|
add("artist", strings.TrimSpace(authors[0]))
|
||||||
|
joined := make([]string, 0, len(authors))
|
||||||
|
for _, a := range authors {
|
||||||
|
a = strings.TrimSpace(a)
|
||||||
|
if a != "" {
|
||||||
|
joined = append(joined, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("album_artist", strings.Join(joined, " & "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(item.Narrators) > 0 {
|
||||||
|
narrators := make([]string, 0, len(item.Narrators))
|
||||||
|
for _, n := range item.Narrators {
|
||||||
|
n = strings.TrimSpace(n)
|
||||||
|
if n != "" {
|
||||||
|
narrators = append(narrators, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("composer", strings.Join(narrators, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Series.Valid {
|
||||||
|
add("grouping", strings.TrimSpace(item.Series.String))
|
||||||
|
if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 {
|
||||||
|
add("----:com.apple.itunes:series-part", strconvFormatFloat(item.SeriesNumber.Float64))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.DatePublished.Valid {
|
||||||
|
add("date", item.DatePublished.Time.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
if len(item.Tags) > 0 && strings.TrimSpace(item.Tags[0]) != "" {
|
||||||
|
add("genre", strings.TrimSpace(item.Tags[0]))
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeDurationSeconds ffprobes a file's duration (0 on any failure).
|
||||||
|
func probeDurationSeconds(path string) float64 {
|
||||||
|
out, err := exec.Command("ffprobe", "-v", "error",
|
||||||
|
"-show_entries", "format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1", path).Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
|
||||||
|
if err != nil || v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAudioTags rewrites the container's tags via ffmpeg stream copy:
|
||||||
|
// `-c copy` never re-encodes, `-map_metadata 0` preserves existing
|
||||||
|
// global tags, and `-map_chapters 0` keeps the m4b chapter list
|
||||||
|
// (chapters live in the header, not a stream). Attempt 1 copies every
|
||||||
|
// stream; attempt 2 — for real-world files carrying text/subtitle/data
|
||||||
|
// tracks the ipod muxer rejects ("Tag text incompatible with output
|
||||||
|
// codec id ...") — maps audio + attached pictures only. Every attempt
|
||||||
|
// is ffprobe-VALIDATED (duration within tolerance of the source)
|
||||||
|
// BEFORE the atomic rename; a failure never touches the original.
|
||||||
|
func (w *MetadataWriter) writeAudioTags(absPath string, item *database.MediaItems) (string, error) {
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("media file missing on disk: %w", err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return "", fmt.Errorf("multi-file audio books take the sidecar path")
|
||||||
|
}
|
||||||
|
|
||||||
|
srcDuration := probeDurationSeconds(absPath)
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(absPath))
|
||||||
|
tmpPath := absPath + ".tmp-write" + ext
|
||||||
|
|
||||||
|
// Shared args; each attempt appends its own stream mapping.
|
||||||
|
base := []string{"-hide_banner", "-loglevel", "error", "-y",
|
||||||
|
"-i", absPath,
|
||||||
|
"-map_metadata", "0", "-map_chapters", "0", "-c", "copy",
|
||||||
|
}
|
||||||
|
for _, kv := range audioTagArgs(item, ext) {
|
||||||
|
base = append(base, "-metadata", kv[0]+"="+kv[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream maps, best fidelity first.
|
||||||
|
streamMaps := [][]string{
|
||||||
|
{"-map", "0"}, // every stream (covers, chapters-as-streams)
|
||||||
|
// Audio + attached pictures; drop text/subtitle/data tracks —
|
||||||
|
// the ipod muxer refuses mov_text-style streams outright.
|
||||||
|
{"-map", "0:a", "-map", "0:t?", "-dn", "-sn"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for i, streamMap := range streamMaps {
|
||||||
|
args := append(append([]string{}, base...), streamMap...)
|
||||||
|
args = append(args, tmpPath)
|
||||||
|
if out, err := exec.Command("ffmpeg", args...).CombinedOutput(); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
lastErr = fmt.Errorf("ffmpeg tag rewrite failed (attempt %d): %s", i+1, strings.TrimSpace(string(out)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation gate: the rewritten container must still hold the
|
||||||
|
// same audio (duration within 1s or 0.5%, whichever is looser).
|
||||||
|
outDuration := probeDurationSeconds(tmpPath)
|
||||||
|
if outDuration <= 0 {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
lastErr = fmt.Errorf("rewritten audio failed validation (ffprobe read no duration)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if srcDuration > 0 {
|
||||||
|
tolerance := math.Max(1.0, srcDuration*0.005)
|
||||||
|
if math.Abs(outDuration-srcDuration) > tolerance {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
lastErr = fmt.Errorf("rewritten audio failed validation (duration %.2fs → %.2fs)", srcDuration, outDuration)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tmpPath, absPath); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", fmt.Errorf("failed to replace audio file: %w", err)
|
||||||
|
}
|
||||||
|
return absPath, nil
|
||||||
|
}
|
||||||
|
return "", lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CBZ ComicInfo.xml (5c) ----
|
||||||
|
|
||||||
|
// comicFieldNames lists the fields the CBZ write round-trips through
|
||||||
|
// the scanner's ComicInfo mapping (media_scanner.go: mergeMetadata's
|
||||||
|
// comic block). Fractional series numbers are omitted — the schema's
|
||||||
|
// Number is an integer and truncating would round-trip a lie.
|
||||||
|
func comicFieldNames(item *database.MediaItems) []string {
|
||||||
|
fields := []string{"title"}
|
||||||
|
if item.Series.Valid && item.Series.String != "" {
|
||||||
|
fields = append(fields, "series")
|
||||||
|
if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 &&
|
||||||
|
item.SeriesNumber.Float64 == float64(int(item.SeriesNumber.Float64)) {
|
||||||
|
fields = append(fields, "series_number")
|
||||||
|
}
|
||||||
|
if item.SeriesCount.Valid && item.SeriesCount.Int32 > 0 {
|
||||||
|
fields = append(fields, "series_count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(item.Authors) > 0 || (item.Author.Valid && item.Author.String != "") {
|
||||||
|
fields = append(fields, "authors")
|
||||||
|
}
|
||||||
|
if item.Description.Valid && item.Description.String != "" {
|
||||||
|
fields = append(fields, "description")
|
||||||
|
}
|
||||||
|
if len(item.Publishers) > 0 || (item.Publisher.Valid && item.Publisher.String != "") {
|
||||||
|
fields = append(fields, "publisher")
|
||||||
|
}
|
||||||
|
if item.Language.Valid && item.Language.String != "" {
|
||||||
|
fields = append(fields, "language")
|
||||||
|
}
|
||||||
|
if item.DatePublished.Valid {
|
||||||
|
fields = append(fields, "date_published")
|
||||||
|
}
|
||||||
|
if len(item.Tags) > 0 {
|
||||||
|
fields = append(fields, "tags")
|
||||||
|
}
|
||||||
|
if item.WebUrl.Valid && item.WebUrl.String != "" {
|
||||||
|
fields = append(fields, "web_url")
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildComicInfoXML renders a ComicInfo.xml (schema v2.x) from the
|
||||||
|
// item's current values — the mirror of the scanner's
|
||||||
|
// extractMetadataFromArchive mapping. Empty values omit their element:
|
||||||
|
// absent means "no data", never "delete".
|
||||||
|
func buildComicInfoXML(item *database.MediaItems) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(xml.Header)
|
||||||
|
b.WriteString(`<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">` + "\n")
|
||||||
|
|
||||||
|
tag := func(name, value string) {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
b.WriteString("\t<" + name + ">" + escXML(value) + "</" + name + ">\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tagInt := func(name string, v int) {
|
||||||
|
if v > 0 {
|
||||||
|
b.WriteString("\t<" + name + ">" + strconv.Itoa(v) + "</" + name + ">\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tag("Title", strings.TrimSpace(item.Title))
|
||||||
|
tag("Series", strings.TrimSpace(item.Series.String))
|
||||||
|
if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 &&
|
||||||
|
item.SeriesNumber.Float64 == float64(int(item.SeriesNumber.Float64)) {
|
||||||
|
tagInt("Number", int(item.SeriesNumber.Float64))
|
||||||
|
}
|
||||||
|
if item.SeriesCount.Valid {
|
||||||
|
tagInt("Count", int(item.SeriesCount.Int32))
|
||||||
|
}
|
||||||
|
if item.Volume.Valid {
|
||||||
|
tagInt("Volume", int(item.Volume.Int32))
|
||||||
|
}
|
||||||
|
|
||||||
|
authors := item.Authors
|
||||||
|
if len(authors) == 0 && item.Author.Valid {
|
||||||
|
authors = []string{item.Author.String}
|
||||||
|
}
|
||||||
|
joinedAuthors := make([]string, 0, len(authors))
|
||||||
|
for _, a := range authors {
|
||||||
|
a = strings.TrimSpace(a)
|
||||||
|
if a != "" {
|
||||||
|
joinedAuthors = append(joinedAuthors, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tag("Writer", strings.Join(joinedAuthors, ", "))
|
||||||
|
|
||||||
|
tag("Summary", item.Description.String)
|
||||||
|
|
||||||
|
publisher := ""
|
||||||
|
if len(item.Publishers) > 0 {
|
||||||
|
publisher = item.Publishers[0]
|
||||||
|
} else if item.Publisher.Valid {
|
||||||
|
publisher = item.Publisher.String
|
||||||
|
}
|
||||||
|
tag("Publisher", strings.TrimSpace(publisher))
|
||||||
|
|
||||||
|
if item.DatePublished.Valid {
|
||||||
|
t := item.DatePublished.Time
|
||||||
|
tagInt("Year", t.Year())
|
||||||
|
tagInt("Month", int(t.Month()))
|
||||||
|
tagInt("Day", t.Day())
|
||||||
|
}
|
||||||
|
|
||||||
|
tag("LanguageISO", strings.TrimSpace(item.Language.String))
|
||||||
|
if item.WebUrl.Valid {
|
||||||
|
tag("Web", strings.TrimSpace(item.WebUrl.String))
|
||||||
|
}
|
||||||
|
|
||||||
|
genre := strings.TrimSpace(item.Genre.String)
|
||||||
|
if genre == "" && len(item.Tags) > 0 {
|
||||||
|
genre = strings.TrimSpace(item.Tags[0])
|
||||||
|
}
|
||||||
|
tag("Genre", genre)
|
||||||
|
if len(item.Tags) > 0 {
|
||||||
|
cleanTags := make([]string, 0, len(item.Tags))
|
||||||
|
for _, t := range item.Tags {
|
||||||
|
t = strings.TrimSpace(t)
|
||||||
|
if t != "" {
|
||||||
|
cleanTags = append(cleanTags, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tag("Tags", strings.Join(cleanTags, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("</ComicInfo>\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCBZComicInfo injects (or replaces) ComicInfo.xml inside the CBZ
|
||||||
|
// — same atomic zip rewrite as the EPUB path, every other entry copied
|
||||||
|
// byte-identical.
|
||||||
|
func (w *MetadataWriter) writeCBZComicInfo(absPath string, item *database.MediaItems) (string, error) {
|
||||||
|
reader, err := zip.OpenReader(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open CBZ: %w", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
newXML := buildComicInfoXML(item)
|
||||||
|
|
||||||
|
tmpPath := absPath + ".tmp-write"
|
||||||
|
if err := rewriteZipArchive(reader.File, "ComicInfo.xml", []byte(newXML), tmpPath, "ComicInfo.xml", []byte(newXML)); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, absPath); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", fmt.Errorf("failed to replace CBZ: %w", err)
|
||||||
|
}
|
||||||
|
return absPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- OPF generation (mirrors parseOPFContent's read set) ----
|
||||||
|
|
||||||
|
// escXML escapes the five XML entities for element and attribute text.
|
||||||
|
func escXML(s string) string {
|
||||||
|
return strings.NewReplacer(
|
||||||
|
"&", "&",
|
||||||
|
"<", "<",
|
||||||
|
">", ">",
|
||||||
|
`"`, """,
|
||||||
|
"'", "'",
|
||||||
|
).Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSidecarOPFXML renders a complete Calibre-compatible package
|
||||||
|
// document for a sidecar.
|
||||||
|
func buildSidecarOPFXML(item *database.MediaItems) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(xml.Header)
|
||||||
|
b.WriteString(`<package version="2.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookhoard-uuid">` + "\n")
|
||||||
|
b.WriteString(`<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">` + "\n")
|
||||||
|
b.WriteString(buildOPFMetadataInner(item))
|
||||||
|
b.WriteString("</metadata>\n</package>\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOPFMetadataInner renders the dc:* elements for the item's
|
||||||
|
// current values — exactly the fields parseOPFContent reads back
|
||||||
|
// (title/subtitle via title-type, creators, subjects, description,
|
||||||
|
// publisher, date, language, isbn identifier, calibre:series metas).
|
||||||
|
// Empty DB values simply omit their element: a sidecar absent-field
|
||||||
|
// means "no data", never "delete this from the file".
|
||||||
|
func buildOPFMetadataInner(item *database.MediaItems) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
title := strings.TrimSpace(item.Title)
|
||||||
|
if title != "" {
|
||||||
|
b.WriteString("\t<dc:title id=\"tmain\">" + escXML(title) + "</dc:title>\n")
|
||||||
|
b.WriteString("\t<meta refines=\"#tmain\" property=\"title-type\">main</meta>\n")
|
||||||
|
}
|
||||||
|
if item.Subtitle.Valid && strings.TrimSpace(item.Subtitle.String) != "" {
|
||||||
|
b.WriteString("\t<dc:title id=\"tsub\">" + escXML(strings.TrimSpace(item.Subtitle.String)) + "</dc:title>\n")
|
||||||
|
b.WriteString("\t<meta refines=\"#tsub\" property=\"title-type\">subtitle</meta>\n")
|
||||||
|
}
|
||||||
|
for _, a := range item.Authors {
|
||||||
|
a = strings.TrimSpace(a)
|
||||||
|
if a == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("\t<dc:creator opf:role=\"aut\">" + escXML(a) + "</dc:creator>\n")
|
||||||
|
}
|
||||||
|
if len(item.Authors) == 0 && item.Author.Valid && strings.TrimSpace(item.Author.String) != "" {
|
||||||
|
b.WriteString("\t<dc:creator opf:role=\"aut\">" + escXML(strings.TrimSpace(item.Author.String)) + "</dc:creator>\n")
|
||||||
|
}
|
||||||
|
if item.Description.Valid && strings.TrimSpace(item.Description.String) != "" {
|
||||||
|
b.WriteString("\t<dc:description>" + escXML(item.Description.String) + "</dc:description>\n")
|
||||||
|
}
|
||||||
|
for _, p := range item.Publishers {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("\t<dc:publisher>" + escXML(p) + "</dc:publisher>\n")
|
||||||
|
break // parser reads the first
|
||||||
|
}
|
||||||
|
if item.DatePublished.Valid {
|
||||||
|
b.WriteString("\t<dc:date>" + item.DatePublished.Time.Format("2006-01-02") + "</dc:date>\n")
|
||||||
|
}
|
||||||
|
if item.Language.Valid && strings.TrimSpace(item.Language.String) != "" {
|
||||||
|
b.WriteString("\t<dc:language>" + escXML(strings.TrimSpace(item.Language.String)) + "</dc:language>\n")
|
||||||
|
}
|
||||||
|
if item.Isbn.Valid && strings.TrimSpace(item.Isbn.String) != "" {
|
||||||
|
b.WriteString("\t<dc:identifier opf:scheme=\"ISBN\">" + escXML(strings.TrimSpace(item.Isbn.String)) + "</dc:identifier>\n")
|
||||||
|
}
|
||||||
|
// The package's unique-identifier needs a matching element: the
|
||||||
|
// scan's OPF uuid when known, else the item id (the parser files
|
||||||
|
// urn:uuid under hash info, never metadata).
|
||||||
|
uid := ""
|
||||||
|
if item.OpfUuid.Valid {
|
||||||
|
uid = strings.TrimSpace(item.OpfUuid.String)
|
||||||
|
}
|
||||||
|
if uid == "" && item.ID.Valid {
|
||||||
|
uid = uuid.UUID(item.ID.Bytes).String()
|
||||||
|
}
|
||||||
|
if uid != "" {
|
||||||
|
b.WriteString("\t<dc:identifier id=\"bookhoard-uuid\">urn:uuid:" + escXML(uid) + "</dc:identifier>\n")
|
||||||
|
}
|
||||||
|
for _, t := range item.Tags {
|
||||||
|
t = strings.TrimSpace(t)
|
||||||
|
if t == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("\t<dc:subject>" + escXML(t) + "</dc:subject>\n")
|
||||||
|
}
|
||||||
|
if item.Series.Valid && strings.TrimSpace(item.Series.String) != "" {
|
||||||
|
b.WriteString("\t<meta name=\"calibre:series\" content=\"" + escXML(item.Series.String) + "\"/>\n")
|
||||||
|
if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 {
|
||||||
|
b.WriteString("\t<meta name=\"calibre:series_index\" content=\"" + strconvFormatFloat(item.SeriesNumber.Float64) + "\"/>\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// metadataFieldNames lists the DB fields a write mirrors — response
|
||||||
|
// contract for the UI ("what got written").
|
||||||
|
func metadataFieldNames(item *database.MediaItems) []string {
|
||||||
|
fields := []string{"title"}
|
||||||
|
if item.Subtitle.Valid && item.Subtitle.String != "" {
|
||||||
|
fields = append(fields, "subtitle")
|
||||||
|
}
|
||||||
|
if (len(item.Authors) > 0) || (item.Author.Valid && item.Author.String != "") {
|
||||||
|
fields = append(fields, "authors")
|
||||||
|
}
|
||||||
|
if item.Description.Valid && item.Description.String != "" {
|
||||||
|
fields = append(fields, "description")
|
||||||
|
}
|
||||||
|
if len(item.Publishers) > 0 || (item.Publisher.Valid && item.Publisher.String != "") {
|
||||||
|
fields = append(fields, "publisher")
|
||||||
|
}
|
||||||
|
if item.DatePublished.Valid {
|
||||||
|
fields = append(fields, "date_published")
|
||||||
|
}
|
||||||
|
if item.Language.Valid && item.Language.String != "" {
|
||||||
|
fields = append(fields, "language")
|
||||||
|
}
|
||||||
|
if item.Isbn.Valid && item.Isbn.String != "" {
|
||||||
|
fields = append(fields, "isbn")
|
||||||
|
}
|
||||||
|
if len(item.Tags) > 0 {
|
||||||
|
fields = append(fields, "tags")
|
||||||
|
}
|
||||||
|
if item.Series.Valid && item.Series.String != "" {
|
||||||
|
fields = append(fields, "series")
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// strconvFormatFloat renders series indexes without trailing noise
|
||||||
|
// (3 → "3", 3.5 → "3.5").
|
||||||
|
func strconvFormatFloat(f float64) string {
|
||||||
|
return fmt.Sprintf("%g", f)
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unit tests for the metadata writer's pure parts (2026-10-27): OPF
|
||||||
|
// generation round-trips through the scanner's OWN parser
|
||||||
|
// (parseOPFContent — write side and read side can never disagree), the
|
||||||
|
// EPUB splice preserves manifest/spine, and the zip rewrite keeps
|
||||||
|
// mimetype first + stored. The ffmpeg audio writer (5b) will extend
|
||||||
|
// this file.
|
||||||
|
|
||||||
|
func fixtureItem() *database.MediaItems {
|
||||||
|
pubDate, _ := time.Parse("2006-01-02", "1853-07-14")
|
||||||
|
return &database.MediaItems{
|
||||||
|
Title: `Progress & "Quotes" <etc>`,
|
||||||
|
Subtitle: pgtype.Text{String: "A Subtitle", Valid: true},
|
||||||
|
Authors: []string{"Ada Lovelace", "Grace Hopper"},
|
||||||
|
Description: pgtype.Text{
|
||||||
|
String: "A <b>description</b> with entities & such", Valid: true,
|
||||||
|
},
|
||||||
|
Publishers: []string{"Analytical Engine Press"},
|
||||||
|
DatePublished: pgtype.Date{Time: pubDate, Valid: true},
|
||||||
|
Language: pgtype.Text{String: "en", Valid: true},
|
||||||
|
Isbn: pgtype.Text{String: "978-0-13-235088-4", Valid: true},
|
||||||
|
Tags: []string{"Science", "Computing"},
|
||||||
|
Series: pgtype.Text{String: "Scientific Memoirs", Valid: true},
|
||||||
|
SeriesNumber: pgtype.Float8{Float64: 3, Valid: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTestEPUB creates a minimal EPUB (mimetype + container + OPF with
|
||||||
|
// manifest/spine) in memory.
|
||||||
|
func buildTestEPUB(t *testing.T, opfContent string) []*zip.File {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := zip.NewWriter(&buf)
|
||||||
|
|
||||||
|
mimetype, _ := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
|
||||||
|
_, _ = mimetype.Write([]byte("application/epub+zip"))
|
||||||
|
|
||||||
|
container, _ := w.Create("META-INF/container.xml")
|
||||||
|
_, _ = container.Write([]byte(`<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`))
|
||||||
|
|
||||||
|
opf, _ := w.Create("OEBPS/content.opf")
|
||||||
|
_, _ = opf.Write([]byte(opfContent))
|
||||||
|
|
||||||
|
chapter, _ := w.Create("OEBPS/chapter1.xhtml")
|
||||||
|
_, _ = chapter.Write([]byte("<html><body>Chapter</body></html>"))
|
||||||
|
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return reader.File
|
||||||
|
}
|
||||||
|
|
||||||
|
const spliceTestOPF = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:title id="t1">Old Title</dc:title>
|
||||||
|
<dc:creator>Old Author</dc:creator>
|
||||||
|
<dc:identifier id="pub-id">urn:uuid:1234</dc:identifier>
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
</manifest>
|
||||||
|
<spine>
|
||||||
|
<itemref idref="ch1"/>
|
||||||
|
</spine>
|
||||||
|
</package>
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestMetadataWriteTargetFor(t *testing.T) {
|
||||||
|
assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".epub"))
|
||||||
|
assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".KEPUB"))
|
||||||
|
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".pdf"))
|
||||||
|
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOPFMetadataInnerRoundTrips(t *testing.T) {
|
||||||
|
item := fixtureItem()
|
||||||
|
xmlInner := buildOPFMetadataInner(item)
|
||||||
|
|
||||||
|
// Wrap in a full package and parse with the SCANNER's parser: the
|
||||||
|
// write must survive the read.
|
||||||
|
doc := `<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">` +
|
||||||
|
xmlInner + `</metadata></package>`
|
||||||
|
md, err := parseOPFContent([]byte(doc))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "Progress & \"Quotes\" <etc>: A Subtitle", md.Title, "main title + calibre-style subtitle join")
|
||||||
|
assert.Equal(t, "Ada Lovelace", md.Author, "first creator")
|
||||||
|
assert.Equal(t, "A <b>description</b> with entities & such", md.Description, "entities unescaped on read")
|
||||||
|
assert.Equal(t, "Analytical Engine Press", md.Publisher)
|
||||||
|
assert.Equal(t, "en", md.Language)
|
||||||
|
assert.Equal(t, "9780132350884", md.ISBN, "normalized on read")
|
||||||
|
assert.Equal(t, "Scientific Memoirs", md.Series)
|
||||||
|
assert.Equal(t, float64(3), md.SeriesNumber)
|
||||||
|
assert.ElementsMatch(t, []string{"Science", "Computing"}, md.Tags)
|
||||||
|
require.NotNil(t, md.PublishDate)
|
||||||
|
assert.Equal(t, "1853-07-14", md.PublishDate.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOPFMetadataInnerOmitsEmptyFields(t *testing.T) {
|
||||||
|
item := &database.MediaItems{Title: "Only A Title"}
|
||||||
|
xmlInner := buildOPFMetadataInner(item)
|
||||||
|
assert.NotContains(t, xmlInner, "dc:creator")
|
||||||
|
assert.NotContains(t, xmlInner, "dc:description")
|
||||||
|
assert.NotContains(t, xmlInner, "calibre:series")
|
||||||
|
assert.NotContains(t, xmlInner, "dc:title id=\"tsub\"", "no subtitle element when unset")
|
||||||
|
|
||||||
|
// Singular-author fallback when the authors array is empty.
|
||||||
|
item = &database.MediaItems{
|
||||||
|
Title: "Fallback",
|
||||||
|
Author: pgtype.Text{String: "Solo Author", Valid: true},
|
||||||
|
}
|
||||||
|
assert.Contains(t, buildOPFMetadataInner(item), "Solo Author")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpliceOPFMetadataPreservesStructure(t *testing.T) {
|
||||||
|
entries := buildTestEPUB(t, spliceTestOPF)
|
||||||
|
item := fixtureItem()
|
||||||
|
|
||||||
|
newOPF, err := spliceOPFMetadata(entries, "OEBPS/content.opf", item)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The old metadata values are gone…
|
||||||
|
out := string(newOPF)
|
||||||
|
assert.NotContains(t, out, "Old Title")
|
||||||
|
assert.NotContains(t, out, "Old Author")
|
||||||
|
// …the new values are in…
|
||||||
|
assert.Contains(t, out, `Progress & "Quotes" <etc>`)
|
||||||
|
assert.Contains(t, out, "Ada Lovelace")
|
||||||
|
// …and manifest/spine/identifier survive untouched.
|
||||||
|
assert.Contains(t, out, `<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>`)
|
||||||
|
assert.Contains(t, out, `<itemref idref="ch1"/>`)
|
||||||
|
assert.Contains(t, out, `urn:uuid:1234`)
|
||||||
|
assert.Contains(t, out, `unique-identifier="pub-id"`, "original opening tag preserved")
|
||||||
|
|
||||||
|
// The spliced OPF parses back with the scanner's parser.
|
||||||
|
md, err := parseOPFContent(newOPF)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, item.Title+": A Subtitle", md.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteEPUBZipKeepsMimetypeFirstAndStored(t *testing.T) {
|
||||||
|
entries := buildTestEPUB(t, spliceTestOPF)
|
||||||
|
item := fixtureItem()
|
||||||
|
newOPF, err := spliceOPFMetadata(entries, "OEBPS/content.opf", item)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tmp := filepath.Join(t.TempDir(), "book.epub")
|
||||||
|
require.NoError(t, rewriteEPUBZip(entries, "OEBPS/content.opf", newOPF, tmp))
|
||||||
|
|
||||||
|
reader, err := zip.OpenReader(tmp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
require.NotEmpty(t, reader.File)
|
||||||
|
assert.Equal(t, "mimetype", reader.File[0].Name, "mimetype must be the first entry")
|
||||||
|
assert.Equal(t, zip.Store, reader.File[0].FileHeader.Method, "mimetype must be uncompressed")
|
||||||
|
|
||||||
|
var opfEntry *zip.File
|
||||||
|
names := []string{}
|
||||||
|
for _, f := range reader.File {
|
||||||
|
names = append(names, f.Name)
|
||||||
|
if f.Name == "OEBPS/content.opf" {
|
||||||
|
opfEntry = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ElementsMatch(t, []string{"mimetype", "META-INF/container.xml", "OEBPS/content.opf", "OEBPS/chapter1.xhtml"}, names)
|
||||||
|
require.NotNil(t, opfEntry)
|
||||||
|
rc, err := opfEntry.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
content, err := io.ReadAll(rc)
|
||||||
|
require.NoError(t, rc.Close())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(content), "Ada Lovelace")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtomicWriteReplacesContent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "metadata.opf")
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte("old"), 0o644))
|
||||||
|
require.NoError(t, atomicWrite(target, []byte("new content")))
|
||||||
|
got, err := os.ReadFile(target)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "new content", string(got))
|
||||||
|
// No temp litter left behind.
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, entries, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioTagArgsMapsRoundTripKeys(t *testing.T) {
|
||||||
|
// Every key here must have a reader in applyAudioTagFallbacks —
|
||||||
|
// that's the round-trip contract with the scanner.
|
||||||
|
item := &database.MediaItems{
|
||||||
|
Title: "The Book",
|
||||||
|
Subtitle: pgtype.Text{String: "The Sub", Valid: true},
|
||||||
|
Authors: []string{"Ada Lovelace", "Grace Hopper"},
|
||||||
|
Narrators: []string{"Simon Vance"},
|
||||||
|
Series: pgtype.Text{String: "Memoirs", Valid: true},
|
||||||
|
SeriesNumber: pgtype.Float8{Float64: 3.5, Valid: true},
|
||||||
|
DatePublished: pgtype.Date{
|
||||||
|
Time: dateOf("1953-04-16"),
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
Tags: []string{"Science"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, kv := range audioTagArgs(item, ".mp3") {
|
||||||
|
got[kv[0]] = kv[1]
|
||||||
|
}
|
||||||
|
assert.Equal(t, "The Book", got["title"])
|
||||||
|
assert.Equal(t, "The Book", got["album"], "the scanner's album rung names the book")
|
||||||
|
assert.Equal(t, "The Sub", got["subtitle"])
|
||||||
|
assert.Equal(t, "Ada Lovelace", got["artist"], "first author")
|
||||||
|
assert.Equal(t, "Ada Lovelace & Grace Hopper", got["album_artist"])
|
||||||
|
assert.Equal(t, "Simon Vance", got["composer"], "narrators ride composer (Audible convention)")
|
||||||
|
assert.Equal(t, "Memoirs", got["grouping"])
|
||||||
|
assert.Equal(t, "3.5", got["----:com.apple.itunes:series-part"])
|
||||||
|
assert.Equal(t, "1953-04-16", got["date"])
|
||||||
|
assert.Equal(t, "Science", got["genre"])
|
||||||
|
|
||||||
|
// Empty fields emit nothing (a tag write must not blank a book).
|
||||||
|
empty := audioTagArgs(&database.MediaItems{Title: "T"}, ".mp3")
|
||||||
|
keys := map[string]bool{}
|
||||||
|
for _, kv := range empty {
|
||||||
|
keys[kv[0]] = true
|
||||||
|
}
|
||||||
|
assert.True(t, keys["title"])
|
||||||
|
assert.False(t, keys["subtitle"])
|
||||||
|
assert.False(t, keys["artist"])
|
||||||
|
assert.False(t, keys["composer"])
|
||||||
|
assert.False(t, keys["grouping"])
|
||||||
|
assert.False(t, keys["genre"])
|
||||||
|
|
||||||
|
// M4B drops subtitle keys in this ffmpeg build — never emitted.
|
||||||
|
m4b := audioTagArgs(item, ".m4b")
|
||||||
|
for _, kv := range m4b {
|
||||||
|
assert.NotEqual(t, "subtitle", kv[0], "mp4 muxer drops subtitle; don't write it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-file audio dispatch (5b): the four containers the ffmpeg tag
|
||||||
|
// writer claims; folder/multi-file audio stays on the sidecar path.
|
||||||
|
func TestAudioTagExts(t *testing.T) {
|
||||||
|
for _, ext := range []string{".mp3", ".flac", ".m4a", ".m4b"} {
|
||||||
|
assert.Equal(t, MetadataTargetAudioTags, MetadataWriteTargetFor(ext), ext)
|
||||||
|
}
|
||||||
|
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".pdf"))
|
||||||
|
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".opus"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CBZ dispatch (5c): ComicInfo injection claims CBZ; CBR/CB7 can't be
|
||||||
|
// rewritten in place and stay on the sidecar path.
|
||||||
|
func TestComicInfoTargetFor(t *testing.T) {
|
||||||
|
assert.Equal(t, MetadataTargetComicInfo, MetadataWriteTargetFor(".cbz"))
|
||||||
|
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cbr"))
|
||||||
|
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cb7"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildComicInfoXML must round-trip through the scanner's OWN ComicInfo
|
||||||
|
// struct (xml.Unmarshal — same parser extractMetadataFromArchive uses).
|
||||||
|
func TestBuildComicInfoXMLRoundTrips(t *testing.T) {
|
||||||
|
pubDate, _ := time.Parse("2006-01-02", "1987-03-10")
|
||||||
|
item := &database.MediaItems{
|
||||||
|
Title: "The Sad Comic <#1>",
|
||||||
|
Series: pgtype.Text{String: "Watchmen", Valid: true},
|
||||||
|
SeriesNumber: pgtype.Float8{Float64: 3, Valid: true},
|
||||||
|
SeriesCount: pgtype.Int4{Int32: 12, Valid: true},
|
||||||
|
Authors: []string{"Alan Moore", "Dave Gibbons"},
|
||||||
|
Description: pgtype.Text{String: "Description & <entities>", Valid: true},
|
||||||
|
Publishers: []string{"DC Comics"},
|
||||||
|
DatePublished: pgtype.Date{
|
||||||
|
Time: pubDate,
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
Language: pgtype.Text{String: "en", Valid: true},
|
||||||
|
WebUrl: pgtype.Text{String: "https://example.com/watchmen", Valid: true},
|
||||||
|
Tags: []string{"Superhero", "Drama"},
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed := ComicInfo{}
|
||||||
|
require.NoError(t, xml.Unmarshal([]byte(buildComicInfoXML(item)), &parsed))
|
||||||
|
|
||||||
|
assert.Equal(t, "The Sad Comic <#1>", parsed.Title)
|
||||||
|
assert.Equal(t, "Watchmen", parsed.Series)
|
||||||
|
assert.Equal(t, 3, parsed.Number)
|
||||||
|
assert.Equal(t, 12, parsed.Count)
|
||||||
|
assert.Equal(t, "Alan Moore, Dave Gibbons", parsed.Writer)
|
||||||
|
assert.Equal(t, "Description & <entities>", parsed.Summary)
|
||||||
|
assert.Equal(t, "DC Comics", parsed.Publisher)
|
||||||
|
assert.Equal(t, 1987, parsed.Year)
|
||||||
|
assert.Equal(t, 3, parsed.Month)
|
||||||
|
assert.Equal(t, 10, parsed.Day)
|
||||||
|
assert.Equal(t, "en", parsed.LanguageISO)
|
||||||
|
assert.Equal(t, "https://example.com/watchmen", parsed.Web)
|
||||||
|
assert.Equal(t, "Superhero", parsed.Genre, "first tag doubles as genre")
|
||||||
|
assert.Equal(t, "Superhero, Drama", parsed.Tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildComicInfoXMLSkipsFractionalNumber(t *testing.T) {
|
||||||
|
// Number is an integer field; a fractional series number must not
|
||||||
|
// round-trip truncated (a lie).
|
||||||
|
item := &database.MediaItems{
|
||||||
|
Title: "X",
|
||||||
|
Series: pgtype.Text{String: "S", Valid: true},
|
||||||
|
SeriesNumber: pgtype.Float8{Float64: 3.5, Valid: true},
|
||||||
|
}
|
||||||
|
parsed := ComicInfo{}
|
||||||
|
require.NoError(t, xml.Unmarshal([]byte(buildComicInfoXML(item)), &parsed))
|
||||||
|
assert.Equal(t, 0, parsed.Number, "fractional series number omitted, not truncated")
|
||||||
|
assert.NotContains(t, buildComicInfoXML(item), "<Number>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The demoted-genre contract in full (885d6140): a format word only
|
||||||
|
// sinks to tags-only when it's ALONE. A blob where ANY candidate maps
|
||||||
|
// to a shelf still wins the genre column; the demoted word never does.
|
||||||
|
func TestPickSmartGenreMixedBlob(t *testing.T) {
|
||||||
|
genre, all := pickSmartGenre([]string{"Audiobook, Science Fiction, Unabridged"})
|
||||||
|
assert.Equal(t, "science fiction", genre, "a recognized shelf wins the column")
|
||||||
|
assert.Contains(t, all, "Audiobook", "the demoted word is still a tag candidate")
|
||||||
|
assert.Contains(t, all, "Unabridged")
|
||||||
|
|
||||||
|
// Format words alone → column empty, everything tags-only.
|
||||||
|
genre, all = pickSmartGenre([]string{"Audiobook, Unabridged"})
|
||||||
|
assert.Empty(t, genre, "demoted words alone never fill the genre column")
|
||||||
|
assert.Contains(t, all, "Audiobook")
|
||||||
|
assert.Contains(t, all, "Unabridged")
|
||||||
|
}
|
||||||
|
|
||||||
|
func dateOf(s string) time.Time {
|
||||||
|
tm, _ := time.Parse("2006-01-02", s)
|
||||||
|
return tm
|
||||||
|
}
|
||||||
@@ -320,3 +320,40 @@ func TestSeriesNumberFloatOrderingValues(t *testing.T) {
|
|||||||
t.Fatal("1 must sort before 1.5")
|
t.Fatal("1 must sort before 1.5")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveSeriesSort composes the toolbar's base key + direction into
|
||||||
|
// the registry key the SQL cascade implements. "recent" is both a base
|
||||||
|
// key AND a full registry key (meaning desc) — the composition must
|
||||||
|
// run BEFORE the full-key passthrough, or Date Added can never sort
|
||||||
|
// ascending (2026-10-27 regression the direction toggle exposed).
|
||||||
|
func TestResolveSeriesSortComposition(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
sort, dir string
|
||||||
|
wantKey, wantD string
|
||||||
|
}{
|
||||||
|
// THE regression: Date Added + toggle up.
|
||||||
|
{"recent", "asc", "recent_asc", "asc"},
|
||||||
|
{"recent", "", "recent", "desc"},
|
||||||
|
{"recent", "desc", "recent", "desc"},
|
||||||
|
{"recent", "bogus", "recent", "desc"},
|
||||||
|
// Full keys (back-compat URLs / JSON API) win as-is.
|
||||||
|
{"name_desc", "", "name_desc", "desc"},
|
||||||
|
{"name_asc", "desc", "name_asc", "asc"},
|
||||||
|
// Base keys compose; default direction applies when dir absent.
|
||||||
|
{"name", "", "name_asc", "asc"},
|
||||||
|
{"name", "desc", "name_desc", "desc"},
|
||||||
|
{"books", "", "books_desc", "desc"},
|
||||||
|
{"books", "asc", "books_asc", "asc"},
|
||||||
|
{"last_read", "asc", "last_read_asc", "asc"},
|
||||||
|
// Unknown/empty → default.
|
||||||
|
{"", "", "recent", "desc"},
|
||||||
|
{"bogus", "asc", "recent", "desc"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
key, dir := ResolveSeriesSort(tc.sort, tc.dir)
|
||||||
|
if key != tc.wantKey || dir != tc.wantD {
|
||||||
|
t.Errorf("ResolveSeriesSort(%q, %q) = (%q, %q), want (%q, %q)",
|
||||||
|
tc.sort, tc.dir, key, dir, tc.wantKey, tc.wantD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -16,6 +17,143 @@ type SeriesInfo struct {
|
|||||||
TotalInSeries int
|
TotalInSeries int
|
||||||
CoverPaths []string
|
CoverPaths []string
|
||||||
LastEntryAt string
|
LastEntryAt string
|
||||||
|
// Viewer-scoped progress aggregates (requesting user): books
|
||||||
|
// finished (percentage >= 0.999) and the series' most recent read.
|
||||||
|
ReadCount int64
|
||||||
|
LastReadAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesPageOptions carries the series page's sort/filter state. Zero
|
||||||
|
// values mean defaults (recent, no filters). Extensible by design:
|
||||||
|
// adding a sort = one entry in seriesSortKeys + a matching CASE arm in
|
||||||
|
// queries.sql (GetDistinctSeries) + one <option> in templates/
|
||||||
|
// series.templ; adding a filter = a param on this struct threaded into
|
||||||
|
// the query's WHERE/HAVING. Unknown values resolve to defaults, so a
|
||||||
|
// stale URL or hand-rolled API call can never reach the SQL with an
|
||||||
|
// unhandled key.
|
||||||
|
type SeriesPageOptions struct {
|
||||||
|
Sort string // base toolbar key or full registry key
|
||||||
|
Dir string // asc | desc ("" = the base key's default)
|
||||||
|
Query string // multi-field: series name, author, or book title
|
||||||
|
Author string // matches when ANY book's author matches
|
||||||
|
Status string // "" | unstarted | reading | finished
|
||||||
|
ViewerUserID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesSortKeys is the registry of sort= values the series page
|
||||||
|
// understands.
|
||||||
|
var seriesSortKeys = map[string]bool{
|
||||||
|
"recent": true, // default: newest series first
|
||||||
|
"recent_asc": true,
|
||||||
|
"name_asc": true,
|
||||||
|
"name_desc": true,
|
||||||
|
"author_asc": true,
|
||||||
|
"author_desc": true,
|
||||||
|
"books_asc": true,
|
||||||
|
"books_desc": true,
|
||||||
|
"progress_asc": true,
|
||||||
|
"progress_desc": true,
|
||||||
|
"last_read_asc": true,
|
||||||
|
"last_read_desc": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesStatusKeys is the registry of status= values. The three states
|
||||||
|
// are mutually exclusive and exhaustive: a series is finished when
|
||||||
|
// every owned book is, reading when any book sits between 0 and the
|
||||||
|
// finish threshold, unstarted when no book has progress > 0.
|
||||||
|
var seriesStatusKeys = map[string]bool{
|
||||||
|
"unstarted": true,
|
||||||
|
"reading": true,
|
||||||
|
"finished": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesBaseSorts is the toolbar's base sort keys (the <select>) with
|
||||||
|
// the direction each one STARTS in (the asc/desc toggle's initial
|
||||||
|
// state). Composed with ResolveSeriesSort into the full registry keys
|
||||||
|
// the SQL cascade implements.
|
||||||
|
var seriesBaseSorts = map[string]string{
|
||||||
|
"recent": "desc",
|
||||||
|
"name": "asc",
|
||||||
|
"author": "asc",
|
||||||
|
"books": "desc",
|
||||||
|
"progress": "desc",
|
||||||
|
"last_read": "desc",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveSeriesSort maps a sort value (either a FULL registry key like
|
||||||
|
// "name_desc" — back-compat with old URLs and the JSON API — or a BASE
|
||||||
|
// toolbar key composed with a direction) to the registry key the SQL
|
||||||
|
// cascade implements, plus the EFFECTIVE direction (for the toggle's
|
||||||
|
// arrow). Unknown values resolve to the default.
|
||||||
|
func ResolveSeriesSort(sort, dir string) (string, string) {
|
||||||
|
sort = strings.TrimSpace(strings.ToLower(sort))
|
||||||
|
dir = strings.ToLower(strings.TrimSpace(dir))
|
||||||
|
if dir != "asc" && dir != "desc" {
|
||||||
|
dir = ""
|
||||||
|
}
|
||||||
|
// "recent" is BOTH a base toolbar key and a registry key (it means
|
||||||
|
// desc). Handle it before the full-key check or dir=asc is silently
|
||||||
|
// swallowed and the direction toggle can never flip Date Added.
|
||||||
|
if sort == "recent" {
|
||||||
|
if dir == "asc" {
|
||||||
|
return "recent_asc", "asc"
|
||||||
|
}
|
||||||
|
return "recent", "desc"
|
||||||
|
}
|
||||||
|
// Full registry key straight from the URL wins as-is.
|
||||||
|
if seriesSortKeys[sort] {
|
||||||
|
return sort, seriesDirOf(sort)
|
||||||
|
}
|
||||||
|
base := sort
|
||||||
|
if base == "" {
|
||||||
|
base = "recent"
|
||||||
|
}
|
||||||
|
defaultDir, known := seriesBaseSorts[base]
|
||||||
|
if !known {
|
||||||
|
// Unknown base: the whole thing falls to the default — an
|
||||||
|
// unhandled sort must not sneak a direction past the registry.
|
||||||
|
return "recent", "desc"
|
||||||
|
}
|
||||||
|
if dir == "" {
|
||||||
|
dir = defaultDir
|
||||||
|
}
|
||||||
|
if base == "recent" && dir == "desc" {
|
||||||
|
return "recent", "desc"
|
||||||
|
}
|
||||||
|
return base + "_" + dir, dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesDirOf derives the display direction of a full registry key.
|
||||||
|
func seriesDirOf(key string) string {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(key, "_asc"):
|
||||||
|
return "asc"
|
||||||
|
case strings.HasSuffix(key, "_desc"):
|
||||||
|
return "desc"
|
||||||
|
case key == "recent":
|
||||||
|
return "desc"
|
||||||
|
default:
|
||||||
|
if d, ok := seriesBaseSorts[key]; ok {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesSortBase strips the direction suffix from a full registry key
|
||||||
|
// ("name_desc" → "name") for the toolbar's base-key <select>.
|
||||||
|
func SeriesSortBase(key string) string {
|
||||||
|
key = strings.TrimSuffix(strings.TrimSuffix(key, "_asc"), "_desc")
|
||||||
|
if _, ok := seriesBaseSorts[key]; ok {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return "recent"
|
||||||
|
}
|
||||||
|
|
||||||
|
// nargText makes an optional text param: empty string → SQL NULL so
|
||||||
|
// the query's IS NULL guard skips the filter.
|
||||||
|
func nargText(v string) pgtype.Text {
|
||||||
|
return pgtype.Text{String: v, Valid: v != ""}
|
||||||
}
|
}
|
||||||
|
|
||||||
type SeriesService struct {
|
type SeriesService struct {
|
||||||
@@ -31,18 +169,35 @@ func NewSeriesService(db *database.Queries) *SeriesService {
|
|||||||
// Pass libraryID for a single-library page, or leave it invalid and pass
|
// Pass libraryID for a single-library page, or leave it invalid and pass
|
||||||
// visibleLibraryIDs for an across-my-libraries page. Both filters compose:
|
// visibleLibraryIDs for an across-my-libraries page. Both filters compose:
|
||||||
// the handler validates any explicit library_id against the visible set.
|
// the handler validates any explicit library_id against the visible set.
|
||||||
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]SeriesInfo, int, error) {
|
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts SeriesPageOptions) ([]SeriesInfo, int, error) {
|
||||||
totalCount, err := s.db.GetDistinctSeriesCount(ctx, database.GetDistinctSeriesCountParams{
|
sortKey, _ := ResolveSeriesSort(opts.Sort, opts.Dir)
|
||||||
|
sort := nargText(sortKey)
|
||||||
|
status := nargText("")
|
||||||
|
if seriesStatusKeys[opts.Status] {
|
||||||
|
status = nargText(opts.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
countParams := database.GetDistinctSeriesCountParams{
|
||||||
|
ViewerUserID: opts.ViewerUserID,
|
||||||
LibraryID: libraryID,
|
LibraryID: libraryID,
|
||||||
VisibleLibraryIds: visibleLibraryIDs,
|
VisibleLibraryIds: visibleLibraryIDs,
|
||||||
})
|
SeriesQuery: nargText(opts.Query),
|
||||||
|
AuthorQuery: nargText(opts.Author),
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
totalCount, err := s.db.GetDistinctSeriesCount(ctx, countParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.GetDistinctSeries(ctx, database.GetDistinctSeriesParams{
|
rows, err := s.db.GetDistinctSeries(ctx, database.GetDistinctSeriesParams{
|
||||||
|
ViewerUserID: opts.ViewerUserID,
|
||||||
LibraryID: libraryID,
|
LibraryID: libraryID,
|
||||||
VisibleLibraryIds: visibleLibraryIDs,
|
VisibleLibraryIds: visibleLibraryIDs,
|
||||||
|
SeriesQuery: countParams.SeriesQuery,
|
||||||
|
AuthorQuery: countParams.AuthorQuery,
|
||||||
|
Status: status,
|
||||||
|
Sort: sort,
|
||||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||||
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
|
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
|
||||||
})
|
})
|
||||||
@@ -76,6 +231,18 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastRead := ""
|
||||||
|
if row.LastReadAt != nil {
|
||||||
|
switch v := row.LastReadAt.(type) {
|
||||||
|
case pgtype.Timestamptz:
|
||||||
|
if v.Valid {
|
||||||
|
lastRead = v.Time.String()
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
lastRead = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if totalInSeries == 0 || totalInSeries < int(row.BookCount) {
|
if totalInSeries == 0 || totalInSeries < int(row.BookCount) {
|
||||||
totalInSeries = int(row.BookCount)
|
totalInSeries = int(row.BookCount)
|
||||||
}
|
}
|
||||||
@@ -87,6 +254,8 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
|
|||||||
TotalInSeries: totalInSeries,
|
TotalInSeries: totalInSeries,
|
||||||
CoverPaths: coverPaths,
|
CoverPaths: coverPaths,
|
||||||
LastEntryAt: lastEntry,
|
LastEntryAt: lastEntry,
|
||||||
|
ReadCount: row.FinishedCount,
|
||||||
|
LastReadAt: lastRead,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Archived Items - Bookhoard</title>
|
<title>Archived Items - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body class={ "theme-" + user.Theme }>
|
<body x-data="admin" class={ "theme-" + user.Theme }>
|
||||||
@Header(user, "/admin/archived")
|
@Header(user, "/admin/archived")
|
||||||
<main class="p-8">
|
<main class="p-8">
|
||||||
<div class="max-w-5xl">
|
<div class="max-w-5xl">
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The archived-items admin page binds its buttons through the page-wide
|
||||||
|
// Alpine component: Purge All is @click="purgeArchivedItems()" and the
|
||||||
|
// per-row Delete/Restore/Merge buttons rely on listeners main.ts binds at
|
||||||
|
// module load. The body tag therefore MUST carry x-data="admin" — the
|
||||||
|
// other admin pages have it, and when this page's body lost it the purge
|
||||||
|
// button rendered as an inert attribute (Alpine only evaluates directives
|
||||||
|
// inside an x-data tree; the click did literally nothing).
|
||||||
|
//
|
||||||
|
// Regression: /admin/archived rendered <body class="theme-…"> with no
|
||||||
|
// Alpine scope, so "Purge All Archived" was dead (prod report 2026-10).
|
||||||
|
func TestAdminArchivedPageHasAlpineScope(t *testing.T) {
|
||||||
|
archivedAt := time.Date(2026, 10, 28, 4, 38, 0, 0, time.UTC)
|
||||||
|
var b strings.Builder
|
||||||
|
err := AdminArchived(User{ID: "u1", Timezone: "UTC"}, []ArchivedItem{{
|
||||||
|
ID: "m1",
|
||||||
|
Title: "Lost Book",
|
||||||
|
Author: "An Author",
|
||||||
|
LibraryName: "Audiobooks",
|
||||||
|
FilePath: "Author/Book/file.m4b",
|
||||||
|
MissingScans: 2,
|
||||||
|
ArchivedAt: &archivedAt,
|
||||||
|
Status: "Archived - pending purge",
|
||||||
|
}}, 0).Render(context.Background(), &b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
out := b.String()
|
||||||
|
if !strings.Contains(out, `<body x-data="admin"`) {
|
||||||
|
t.Error("archived page body missing x-data=\"admin\" — the Alpine-bound purge button would be dead")
|
||||||
|
}
|
||||||
|
// The action buttons the page depends on must be present in the same
|
||||||
|
// render, so the pin cannot pass on an empty shell.
|
||||||
|
for _, want := range []string{"purgeArchivedItems()", `data-delete-archived="m1"`} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("archived page missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Archived Items - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Archived Items - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body x-data=\"admin\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(retentionDays)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(retentionDays)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 31, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 30, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -135,7 +135,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 66, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 65, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -153,7 +153,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 68, Col: 66}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 67, Col: 66}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -171,7 +171,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 70, Col: 103}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 69, Col: 103}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -184,7 +184,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.LibraryName)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.LibraryName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 72, Col: 86}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 71, Col: 86}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -197,7 +197,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 73, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 72, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -215,7 +215,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 78, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 77, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -228,7 +228,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MatchID)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MatchID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 79, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 78, Col: 46}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -241,7 +241,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Merge this archived copy's reading data into the active copy \"" + item.MatchTitle + "\" (" + item.MatchLibraryName + ") and remove the archived entry")
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Merge this archived copy's reading data into the active copy \"" + item.MatchTitle + "\" (" + item.MatchLibraryName + ") and remove the archived entry")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 81, Col: 174}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 80, Col: 174}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -267,7 +267,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 89, Col: 37}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 88, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -288,7 +288,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 98, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 97, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Library Management - Bookhoard</title>
|
<title>Library Management - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
@@ -267,6 +266,18 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
|
|||||||
for _, folder := range folders {
|
for _, folder := range folders {
|
||||||
<div class="flex items-center justify-between gap-2 p-2 rounded-lg" style="background-color: var(--bg-primary);">
|
<div class="flex items-center justify-between gap-2 p-2 rounded-lg" style="background-color: var(--bg-primary);">
|
||||||
<code class="text-xs flex-1 truncate" style="color: var(--text-primary)">{ folder.FolderPath }</code>
|
<code class="text-xs flex-1 truncate" style="color: var(--text-primary)">{ folder.FolderPath }</code>
|
||||||
|
<button
|
||||||
|
class="icon-btn h-7 w-7 shrink-0"
|
||||||
|
hx-post={ "/api/libraries/" + libraryID + "/scan" }
|
||||||
|
hx-vals={ hxValsFolderScan(folder.FolderPath) }
|
||||||
|
hx-target="#scan-indicator"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="Scan this folder?"
|
||||||
|
title="Scan this folder"
|
||||||
|
aria-label="Scan this folder"
|
||||||
|
>
|
||||||
|
@Icon("refresh", "h-3.5 w-3.5")
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="icon-btn h-7 w-7 shrink-0"
|
class="icon-btn h-7 w-7 shrink-0"
|
||||||
hx-delete={ "/admin/library/" + libraryID + "/folders" }
|
hx-delete={ "/admin/library/" + libraryID + "/folders" }
|
||||||
|
|||||||
+266
-232
@@ -31,7 +31,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Library Management - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Library Management - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(archivedCount)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(archivedCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 45, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 44, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -220,7 +220,7 @@ func LibraryTypeOptions(types []database.LibraryTypes) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Name)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 186, Col: 24}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 185, Col: 24}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -233,7 +233,7 @@ func LibraryTypeOptions(types []database.LibraryTypes) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(LibraryTypeDisplayName(t.Name))
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(LibraryTypeDisplayName(t.Name))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 186, Col: 59}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 185, Col: 59}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -303,7 +303,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 210, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 209, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -321,7 +321,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 212, Col: 95}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 211, Col: 95}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -343,7 +343,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 217, Col: 26}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 216, Col: 26}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -365,7 +365,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 222, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 221, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -383,7 +383,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 228, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 227, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -404,7 +404,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 238, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 237, Col: 32}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -425,7 +425,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 247, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 246, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -500,20 +500,20 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 269, Col: 99}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 268, Col: 99}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</code> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-delete=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</code> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-post=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/scan")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 272, Col: 62}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 271, Col: 57}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -524,28 +524,62 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderScan(folder.FolderPath))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 273, Col: 55}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 272, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-target=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-target=\"#scan-indicator\" hx-swap=\"innerHTML\" hx-confirm=\"Scan this folder?\" title=\"Scan this folder\" aria-label=\"Scan this folder\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("refresh", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</button> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-delete=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 274, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 283, Col: 62}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" hx-swap=\"innerHTML\" hx-confirm=\"Remove this folder from the library?\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" hx-vals=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 string
|
||||||
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, 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: 285, Col: 49}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" hx-swap=\"innerHTML\" hx-confirm=\"Remove this folder from the library?\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -553,82 +587,82 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</button></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<form class=\"flex gap-2\" hx-post=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<form class=\"flex gap-2\" hx-post=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var21 string
|
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 80}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" hx-target=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var22 string
|
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 124}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" hx-swap=\"innerHTML\"><input type=\"text\" name=\"folder_path\" id=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 288, Col: 37}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 295, Col: 80}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" placeholder=\"/path/to/books\" class=\"input flex-1\" required> <button type=\"button\" data-library-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" hx-target=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 295, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 295, Col: 124}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" data-target-input=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" hx-swap=\"innerHTML\"><input type=\"text\" name=\"folder_path\" id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 296, Col: 52}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 299, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" @click=\"openFolderBrowser($el.dataset.targetInput, $el.dataset.libraryId)\" class=\"btn btn-secondary text-sm shrink-0\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" placeholder=\"/path/to/books\" class=\"input flex-1\" required> <button type=\"button\" data-library-id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var26 string
|
||||||
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 306, 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: 307, Col: 52}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" @click=\"openFolderBrowser($el.dataset.targetInput, $el.dataset.libraryId)\" class=\"btn btn-secondary text-sm shrink-0\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -636,7 +670,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Browse</button> <button type=\"submit\" class=\"btn btn-primary text-sm shrink-0\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "Browse</button> <button type=\"submit\" class=\"btn btn-primary text-sm shrink-0\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -644,12 +678,12 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "Add</button></form></div><!-- User Visibility -->")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "Add</button></form></div><!-- User Visibility -->")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(users) > 0 {
|
if len(users) > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<div><div class=\"flex items-center gap-2 mb-3\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<div><div class=\"flex items-center gap-2 mb-3\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -657,115 +691,115 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<h4 class=\"text-sm font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">User Access</h4></div><div class=\"space-y-1\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<h4 class=\"text-sm font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">User Access</h4></div><div class=\"space-y-1\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<label class=\"flex items-center gap-3 cursor-pointer p-2 rounded-lg transition-colors hover:bg-surface-hover\"><input type=\"checkbox\" class=\"w-4 h-4 rounded\" name=\"is_visible\" value=\"true\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<label class=\"flex items-center gap-3 cursor-pointer p-2 rounded-lg transition-colors hover:bg-surface-hover\"><input type=\"checkbox\" class=\"w-4 h-4 rounded\" name=\"is_visible\" value=\"true\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if isUserVisible(u.ID, visibility) {
|
if isUserVisible(u.ID, visibility) {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " checked")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " checked")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " hx-post=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " hx-post=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var26 string
|
|
||||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 325, Col: 63}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" hx-vals=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var27 string
|
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 326, Col: 47}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" hx-trigger=\"change\" hx-target=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var28 string
|
var templ_7745c5c3_Var28 string
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 328, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 336, Col: 63}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" hx-swap=\"innerHTML\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" hx-vals=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 331, Col: 76}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 337, Col: 47}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" hx-trigger=\"change\" hx-target=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var30 string
|
var templ_7745c5c3_Var30 string
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 332, Col: 75}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 339, Col: 49}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span></label>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" hx-swap=\"innerHTML\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</div></div>")
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 342, Col: 76}
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<!-- Processing Issues -->")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if issueCount > 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<div><a href=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var31 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 341, Col: 57}
|
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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: 343, Col: 75}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span></label>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<!-- Processing Issues -->")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if issueCount > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var33 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 352, Col: 57}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -774,25 +808,25 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if issueCount == 1 {
|
if issueCount == 1 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span>1 processing issue</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span>1 processing issue</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var32 string
|
var templ_7745c5c3_Var34 string
|
||||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
|
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 346, Col: 24}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 357, Col: 24}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " processing issues</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " processing issues</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -801,51 +835,51 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</a></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</a></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<!-- Actions --><div class=\"flex gap-2 pt-2 border-t\" style=\"border-color: var(--border);\"><button data-edit-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<!-- Actions --><div class=\"flex gap-2 pt-2 border-t\" style=\"border-color: var(--border);\"><button data-edit-id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var33 string
|
|
||||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 355, Col: 28}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" data-edit-name=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var34 string
|
|
||||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 356, Col: 33}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" data-edit-desc=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var35 string
|
var templ_7745c5c3_Var35 string
|
||||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 357, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 366, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" @click=\"openEditModal($el.dataset.editId, $el.dataset.editName, $el.dataset.editDesc)\" class=\"btn btn-secondary text-sm\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" data-edit-name=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var36 string
|
||||||
|
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 367, 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: 368, Col: 40}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" @click=\"openEditModal($el.dataset.editId, $el.dataset.editName, $el.dataset.editDesc)\" class=\"btn btn-secondary text-sm\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -853,33 +887,33 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "Edit Details</button> <button data-delete-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Edit Details</button> <button data-delete-id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var36 string
|
var templ_7745c5c3_Var38 string
|
||||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 365, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 376, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" data-delete-name=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" data-delete-name=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var37 string
|
var templ_7745c5c3_Var39 string
|
||||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 366, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 377, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" @click=\"openDeleteModal($el.dataset.deleteId, $el.dataset.deleteName)\" class=\"btn btn-danger text-sm\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" @click=\"openDeleteModal($el.dataset.deleteId, $el.dataset.deleteName)\" class=\"btn btn-danger text-sm\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -887,7 +921,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Delete Library</button></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "Delete Library</button></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -911,12 +945,12 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var38 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var38 == nil {
|
if templ_7745c5c3_Var40 == nil {
|
||||||
templ_7745c5c3_Var38 = templ.NopComponent
|
templ_7745c5c3_Var40 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<div><div class=\"flex items-center gap-2 mb-3 p-2 rounded-lg\" style=\"background-color: var(--bg-primary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<div><div class=\"flex items-center gap-2 mb-3 p-2 rounded-lg\" style=\"background-color: var(--bg-primary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -924,38 +958,38 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<code class=\"text-xs flex-1 truncate\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<code class=\"text-xs flex-1 truncate\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var39 string
|
var templ_7745c5c3_Var41 string
|
||||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
|
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 381, Col: 89}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 392, Col: 89}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</code></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</code></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if parentPath != "" {
|
if parentPath != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" hx-get=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" hx-get=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var40 string
|
var templ_7745c5c3_Var42 string
|
||||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 387, Col: 117}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 398, Col: 117}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -963,30 +997,30 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<span>..</span></button>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<span>..</span></button>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<div class=\"space-y-1 max-h-64 overflow-y-auto\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<div class=\"space-y-1 max-h-64 overflow-y-auto\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\" hx-get=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\" hx-get=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var41 string
|
var templ_7745c5c3_Var43 string
|
||||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 400, Col: 118}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 411, Col: 118}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -994,51 +1028,51 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"truncate\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<span class=\"truncate\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var42 string
|
|
||||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 405, Col: 40}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</span></button>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</div><div class=\"mt-4 flex justify-end\"><button type=\"button\" data-target-input=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var43 string
|
|
||||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 412, Col: 35}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" data-current-path=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var44 string
|
var templ_7745c5c3_Var44 string
|
||||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 416, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" @click=\"pickBrowsePath($event)\" class=\"btn btn-primary text-sm\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</span></button>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</div><div class=\"mt-4 flex justify-end\"><button type=\"button\" data-target-input=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var45 string
|
||||||
|
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 423, Col: 35}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" data-current-path=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var46 string
|
||||||
|
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 424, Col: 35}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\" @click=\"pickBrowsePath($event)\" class=\"btn btn-primary text-sm\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1046,7 +1080,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "Select This Folder</button></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "Select This Folder</button></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
|||||||
</head>
|
</head>
|
||||||
<body class={ "theme-" + user.Theme }>
|
<body class={ "theme-" + user.Theme }>
|
||||||
@Header(user, "/admin/library")
|
@Header(user, "/admin/library")
|
||||||
<main class="p-8">
|
<!-- The retry queue component: per-issue Retry buttons and Retry all
|
||||||
|
run each issue through the individual scanner sequentially (one
|
||||||
|
book in flight at a time). The whole variable section below is a
|
||||||
|
templ partial (#issues-content) so the client can re-render
|
||||||
|
server truth after the queue drains — htmx.ajax swap, never
|
||||||
|
JSON-into-innerHTML. -->
|
||||||
|
<main class="p-8" x-data="processingIssues" data-library-id={ libraryID }>
|
||||||
<div class="mx-auto max-w-4xl">
|
<div class="mx-auto max-w-4xl">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -25,9 +31,24 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
|||||||
<p class="text-sm" style="color: var(--text-secondary)">Items that couldn't be processed in this library</p>
|
<p class="text-sm" style="color: var(--text-secondary)">Items that couldn't be processed in this library</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
|
@ProcessingIssuesContent(libraryID, issues, stats)
|
||||||
|
</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 -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 flex-1">
|
||||||
if stats.ErrorCount > 0 {
|
if stats.ErrorCount > 0 {
|
||||||
<div class="stat-card" style="border-left: 4px solid var(--status-danger);">
|
<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);">
|
<div class="flex items-center gap-2 mb-2" style="color: var(--status-danger);">
|
||||||
@@ -56,6 +77,21 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Retry all: right of the errors row. Queues every issue
|
||||||
|
currently on the page through the individual scanner,
|
||||||
|
one at a time — deliberately NOT a whole-library rescan. -->
|
||||||
|
<button
|
||||||
|
@click="retryAll()"
|
||||||
|
:disabled="queueRunning"
|
||||||
|
class="btn btn-primary shrink-0"
|
||||||
|
>
|
||||||
|
<span x-show="!queueRunning" class="inline-flex items-center gap-2">
|
||||||
|
@Icon("refresh", "h-4 w-4")
|
||||||
|
<span>Retry all</span>
|
||||||
|
</span>
|
||||||
|
<span x-show="queueRunning" x-text="queueRunning ? (queueDone + ' / ' + queueTotal) : ''"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
<div class="card p-8 text-center">
|
<div class="card p-8 text-center">
|
||||||
@@ -68,7 +104,7 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
|||||||
<!-- Issues List -->
|
<!-- Issues List -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
<div class="card p-6" id={ "issue-" + issue.ID }>
|
<div class="card p-6" id={ "issue-" + issue.ID } data-issue-id={ issue.ID }>
|
||||||
<div class="flex justify-between items-start gap-4 mb-4">
|
<div class="flex justify-between items-start gap-4 mb-4">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h4 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ issue.Title }</h4>
|
<h4 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ issue.Title }</h4>
|
||||||
@@ -91,6 +127,14 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 mt-4">
|
<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" {
|
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||||
<button
|
<button
|
||||||
hx-post={ "/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve" }
|
hx-post={ "/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve" }
|
||||||
@@ -109,7 +153,4 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,20 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<main class=\"p-8\"><div class=\"mx-auto max-w-4xl\"><div class=\"mb-8\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!-- The retry queue component: per-issue Retry buttons and Retry all\n\t\t run each issue through the individual scanner sequentially (one\n\t\t book in flight at a time). The whole variable section below is a\n\t\t templ partial (#issues-content) so the client can re-render\n\t\t server truth after the queue drains — htmx.ajax swap, never\n\t\t JSON-into-innerHTML. --><main class=\"p-8\" x-data=\"processingIssues\" data-library-id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 21, Col: 73}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><div class=\"mx-auto max-w-4xl\"><div class=\"mb-8\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -67,17 +80,59 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Processing Issues</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Items that couldn't be processed in this library</p></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Processing Issues</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Items that couldn't be processed in this library</p></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
|
templ_7745c5c3_Err = ProcessingIssuesContent(libraryID, issues, stats).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div></main></body></html>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessingIssuesContent is the swappable body of the Processing Issues
|
||||||
|
// page: the severity stat cards (with the Retry-all control on the right
|
||||||
|
// of the errors row) and the issue card list. Rendered by the page and by
|
||||||
|
// the /admin/libraries/:id/issues/content partial the retry queue swaps
|
||||||
|
// in after draining.
|
||||||
|
func ProcessingIssuesContent(libraryID string, issues []ProcessingIssueData, stats IssueStats) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var5 == nil {
|
||||||
|
templ_7745c5c3_Var5 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div id=\"issues-content\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(issues) > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"flex items-start justify-between gap-6 mb-8\"><!-- Stats Cards --><div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 flex-1\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if stats.ErrorCount > 0 {
|
if stats.ErrorCount > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-danger);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-danger);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-danger);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-danger);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -85,43 +140,16 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Errors</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Errors</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 37, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 58, Col: 90}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</p></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if stats.WarningCount > 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-warning);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-warning);\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Warnings</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var5 string
|
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 46, Col: 94}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -130,25 +158,25 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stats.InfoCount > 0 {
|
if stats.WarningCount > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-info);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-info);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-warning);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-warning);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Icon("info", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Info</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Warnings</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 55, Col: 91}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 67, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -157,13 +185,48 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
if stats.InfoCount > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-info);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-info);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("info", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Info</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 89}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><!-- Retry all: right of the errors row. Queues every issue\n\t\t\t\t currently on the page through the individual scanner,\n\t\t\t\t one at a time — deliberately NOT a whole-library rescan. --><button @click=\"retryAll()\" :disabled=\"queueRunning\" class=\"btn btn-primary shrink-0\"><span x-show=\"!queueRunning\" class=\"inline-flex items-center gap-2\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span>Retry all</span></span> <span x-show=\"queueRunning\" x-text=\"queueRunning ? (queueDone + ' / ' + queueTotal) : ''\"></span></button></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"card p-8 text-center\"><span class=\"grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"card p-8 text-center\"><span class=\"grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -171,198 +234,232 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No processing issues found for this library.</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No processing issues found for this library.</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<!-- Issues List --> <div class=\"space-y-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<!-- Issues List --> <div class=\"space-y-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"card p-6\" id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"card p-6\" id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var7 string
|
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 71, Col: 54}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\"><div class=\"flex justify-between items-start gap-4 mb-4\"><div class=\"flex-1 min-w-0\"><h4 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var8 string
|
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 98}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 75, Col: 96}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 107, Col: 51}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p><div class=\"text-sm space-y-1\" style=\"color: var(--text-secondary)\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Type:</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-issue-id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(issue.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 140}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 107, Col: 78}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Format:</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"><div class=\"flex justify-between items-start gap-4 mb-4\"><div class=\"flex-1 min-w-0\"><h4 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 144}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 110, Col: 95}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">File:</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 139}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 111, Col: 93}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Library:</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p><div class=\"text-sm space-y-1\" style=\"color: var(--text-secondary)\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Type:</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 80, Col: 149}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 113, Col: 137}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></div></div><div class=\"ml-2 shrink-0\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Format:</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if issue.Severity == "error" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<span class=\"badge status-failed\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 85, Col: 62}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 114, Col: 141}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span>")
|
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
|
|
||||||
}
|
|
||||||
} else if issue.Severity == "warning" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"badge status-pending\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 87, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 115, Col: 136}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span>")
|
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
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"badge status-processing\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 89, Col: 66}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 116, Col: 146}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</p></div></div><div class=\"ml-2 shrink-0\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
if issue.Severity == "error" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></div><div class=\"flex gap-3 mt-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"badge status-failed\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<button hx-post=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 113}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 121, Col: 59}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" hx-target=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if issue.Severity == "warning" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span class=\"badge status-pending\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 97, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 123, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" hx-swap=\"outerHTML\" hx-confirm=\"Dismiss this issue?\" class=\"btn btn-secondary text-sm\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span class=\"badge status-processing\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 string
|
||||||
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 125, Col: 63}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></div><div class=\"flex gap-3 mt-4\"><button class=\"btn btn-primary text-sm issue-retry-btn\" @click=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 string
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("retryOne(" + JSONArg(issue.ID) + ")")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 132, Col: 54}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" :disabled=\"queueRunning\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "Retry</button> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<button hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 string
|
||||||
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 140, Col: 111}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 141, Col: 41}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" hx-swap=\"outerHTML\" hx-confirm=\"Dismiss this issue?\" class=\"btn btn-secondary text-sm\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -370,22 +467,22 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Dismiss</button>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "Dismiss</button>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div></main></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>System Settings - Bookhoard</title>
|
<title>System Settings - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
@@ -35,7 +34,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
|
|||||||
<span>{ errorMessage }</span>
|
<span>{ errorMessage }</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
|
<form id="settings-form" data-settings-save="settings" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
|
||||||
<div class="card p-6">
|
<div class="card p-6">
|
||||||
<div class="flex items-center gap-2 mb-6">
|
<div class="flex items-center gap-2 mb-6">
|
||||||
@Icon("globe", "h-5 w-5 shrink-0")
|
@Icon("globe", "h-5 w-5 shrink-0")
|
||||||
@@ -54,7 +53,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
|
|||||||
<p class="text-sm mt-2" style="color: var(--text-secondary)">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p>
|
<p class="text-sm mt-2" style="color: var(--text-secondary)">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" hx-disabled-elt="this">
|
||||||
@Icon("save", "h-4 w-4")
|
@Icon("save", "h-4 w-4")
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -129,9 +128,9 @@ templ ScanSettingsSection(scanSettings ScanSettingsData) {
|
|||||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Scanning</h3>
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Scanning</h3>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
|
data-settings-save="scan"
|
||||||
hx-put="/admin/settings/scan"
|
hx-put="/admin/settings/scan"
|
||||||
hx-target="#scan-settings-section"
|
hx-swap="none"
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
@@ -165,7 +164,7 @@ templ ScanSettingsSection(scanSettings ScanSettingsData) {
|
|||||||
<p class="text-sm mt-2" style="color: var(--text-secondary)">How often to poll libraries for changes (1–3600 seconds). Default: 60.</p>
|
<p class="text-sm mt-2" style="color: var(--text-secondary)">How often to poll libraries for changes (1–3600 seconds). Default: 60.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" hx-disabled-elt="this">
|
||||||
@Icon("save", "h-4 w-4")
|
@Icon("save", "h-4 w-4")
|
||||||
Save Scan Settings
|
Save Scan Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -220,6 +219,7 @@ templ TunableSettingRow(e SettingEntry) {
|
|||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
class="flex items-center gap-2 shrink-0"
|
class="flex items-center gap-2 shrink-0"
|
||||||
|
data-settings-save="tunable"
|
||||||
hx-put="/admin/settings/tunable"
|
hx-put="/admin/settings/tunable"
|
||||||
hx-target={ "#status-" + e.Key }
|
hx-target={ "#status-" + e.Key }
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
@@ -252,7 +252,7 @@ templ TunableSettingRow(e SettingEntry) {
|
|||||||
class="input py-1.5 text-sm w-40"
|
class="input py-1.5 text-sm w-40"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
<button type="submit" class="btn btn-secondary px-3 py-1.5 text-sm">
|
<button type="submit" class="btn btn-secondary px-3 py-1.5 text-sm" hx-disabled-elt="this">
|
||||||
@Icon("save", "h-3.5 w-3.5")
|
@Icon("save", "h-3.5 w-3.5")
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
@@ -275,3 +275,5 @@ templ AboutSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>System Settings - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>System Settings - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 35, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 34, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -103,7 +103,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<form id=\"settings-form\" hx-put=\"/api/system/config\" hx-target=\"#settings-form\" hx-swap=\"outerHTML\"><div class=\"card p-6\"><div class=\"flex items-center gap-2 mb-6\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<form id=\"settings-form\" data-settings-save=\"settings\" hx-put=\"/api/system/config\" hx-target=\"#settings-form\" hx-swap=\"outerHTML\"><div class=\"card p-6\"><div class=\"flex items-center gap-2 mb-6\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -118,13 +118,13 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 49, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 48, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" placeholder=\"https://books.example.com\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p></div><div class=\"mt-6 flex justify-end\"><button type=\"submit\" class=\"btn btn-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" placeholder=\"https://books.example.com\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p></div><div class=\"mt-6 flex justify-end\"><button type=\"submit\" class=\"btn btn-primary\" hx-disabled-elt=\"this\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -395,7 +395,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 110, Col: 145}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 109, Col: 145}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -408,7 +408,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 111, Col: 144}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 110, Col: 144}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -421,7 +421,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 112, Col: 152}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 111, Col: 152}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -484,7 +484,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Scanning</h3></div><form hx-put=\"/admin/settings/scan\" hx-target=\"#scan-settings-section\" hx-swap=\"outerHTML\"><div class=\"space-y-4\"><div class=\"flex items-center justify-between gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\">Auto-Scan</label><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Watch libraries for file changes on startup</p></div><label class=\"relative inline-flex items-center cursor-pointer shrink-0\"><input type=\"checkbox\" name=\"auto_scan_enabled\" value=\"true\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Scanning</h3></div><form data-settings-save=\"scan\" hx-put=\"/admin/settings/scan\" hx-swap=\"none\"><div class=\"space-y-4\"><div class=\"flex items-center justify-between gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\">Auto-Scan</label><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Watch libraries for file changes on startup</p></div><label class=\"relative inline-flex items-center cursor-pointer shrink-0\"><input type=\"checkbox\" name=\"auto_scan_enabled\" value=\"true\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -501,13 +501,13 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 159, Col: 69}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 158, Col: 69}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" min=\"1\" max=\"3600\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">How often to poll libraries for changes (1–3600 seconds). Default: 60.</p></div><div class=\"flex justify-end\"><button type=\"submit\" class=\"btn btn-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" min=\"1\" max=\"3600\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">How often to poll libraries for changes (1–3600 seconds). Default: 60.</p></div><div class=\"flex justify-end\"><button type=\"submit\" class=\"btn btn-primary\" hx-disabled-elt=\"this\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -593,7 +593,7 @@ func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.C
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 199, Col: 112}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 198, Col: 112}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -651,7 +651,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 214, Col: 94}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 213, Col: 94}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -669,7 +669,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 216, Col: 74}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 215, Col: 74}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -687,7 +687,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 218, Col: 74}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 217, Col: 74}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -698,7 +698,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div><form class=\"flex items-center gap-2 shrink-0\" hx-put=\"/admin/settings/tunable\" hx-target=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div><form class=\"flex items-center gap-2 shrink-0\" data-settings-save=\"tunable\" hx-put=\"/admin/settings/tunable\" hx-target=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -832,7 +832,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<button type=\"submit\" class=\"btn btn-secondary px-3 py-1.5 text-sm\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<button type=\"submit\" class=\"btn btn-secondary px-3 py-1.5 text-sm\" hx-disabled-elt=\"this\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Users - Bookhoard Admin</title>
|
<title>Users - Bookhoard Admin</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
templ_7745c5c3_Var2 = templ.NopComponent
|
templ_7745c5c3_Var2 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Users - Bookhoard Admin</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Users - Bookhoard Admin</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 102, Col: 33}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 101, Col: 33}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -149,7 +149,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 111, Col: 96}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 110, Col: 96}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -172,7 +172,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 116, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 115, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -185,7 +185,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 121, Col: 73}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 120, Col: 73}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -208,7 +208,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 138, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 137, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -221,7 +221,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 139, Col: 48}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 138, Col: 48}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -259,7 +259,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 150, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 149, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -272,7 +272,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 154, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 153, Col: 63}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -311,7 +311,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 173, Col: 53}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 172, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -324,7 +324,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 174, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 173, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>{ book.Title } - Bookhoard</title>
|
<title>{ book.Title } - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
@@ -603,6 +602,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
|||||||
@AnnotationsBanner(book)
|
@AnnotationsBanner(book)
|
||||||
if user.Role == "admin" {
|
if user.Role == "admin" {
|
||||||
@MetadataEditorModal(book)
|
@MetadataEditorModal(book)
|
||||||
|
@WriteToFileConfirmDialog(book)
|
||||||
}
|
}
|
||||||
<!-- Review editor: plain textarea over the RAW markdown (what
|
<!-- Review editor: plain textarea over the RAW markdown (what
|
||||||
storage and sync carry); rendering happens only on display. -->
|
storage and sync carry); rendering happens only on display. -->
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
id="metadata-editor-modal"
|
id="metadata-editor-modal"
|
||||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||||
style="background-color: var(--surface-overlay);"
|
style="background-color: var(--surface-overlay);"
|
||||||
|
data-write-file-path={ book.FilePath }
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="card w-full max-w-5xl my-8 flex flex-col max-h-[90vh]"
|
class="card w-full max-w-5xl my-8 flex flex-col max-h-[90vh]"
|
||||||
@@ -683,6 +684,18 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
@click="writeMetadataToFile()"
|
||||||
|
:disabled="writingFile"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
:title="metadataWriteHint()"
|
||||||
|
>
|
||||||
|
<span x-show="!writingFile" class="inline-flex items-center gap-2">@Icon("save", "h-4 w-4")<span>Write to file</span></span>
|
||||||
|
<svg x-show="writingFile" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
<button
|
<button
|
||||||
@@ -703,3 +716,52 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteToFileConfirmDialog is the in-app replacement for the browser
|
||||||
|
// confirm() on the metadata editor's "Write to file" action (CSP and
|
||||||
|
// UX: no native dialogs). The target-specific copy comes from the
|
||||||
|
// bookDetail Alpine component (same scope as the editor modal).
|
||||||
|
templ WriteToFileConfirmDialog(book handlers.MediaDetail) {
|
||||||
|
<div
|
||||||
|
id="write-confirm-dialog"
|
||||||
|
x-show="writeConfirmOpen"
|
||||||
|
x-cloak
|
||||||
|
x-transition.opacity
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||||
|
style="background-color: var(--surface-overlay);"
|
||||||
|
>
|
||||||
|
<div class="card w-full max-w-md p-6" style="box-shadow: var(--shadow-pop);">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("save", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Write metadata to file?</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm mb-2" style="color: var(--text-secondary)" x-text="writeConfirmMessage()"></p>
|
||||||
|
<p class="text-xs mb-6" style="color: var(--text-secondary)">
|
||||||
|
Your pending edits are saved first, then the file is rewritten and re-scanned.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="writeConfirmOpen = false"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="performWriteToFile()"
|
||||||
|
:disabled="writingFile"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
<span x-show="!writingFile" class="inline-flex items-center gap-2">@Icon("save", "h-4 w-4")<span>Save & write</span></span>
|
||||||
|
<svg x-show="writingFile" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -73,7 +73,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FormatGroup)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FormatGroup)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 48, Col: 39}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 47, Col: 39}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -115,7 +115,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(book.LibraryID))
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(book.LibraryID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 49, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 48, Col: 49}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -128,7 +128,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", getBookRating(book.Rating)))
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", getBookRating(book.Rating)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 50, Col: 62}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 49, Col: 62}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -141,7 +141,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflictID(book.ActiveConflict))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflictID(book.ActiveConflict))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 51, Col: 53}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 50, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -154,7 +154,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflictWinnerSource(book.ActiveConflict))
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflictWinnerSource(book.ActiveConflict))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 52, Col: 67}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 51, Col: 67}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -194,7 +194,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(templ.SafeCSS("background-image: url(" + cssURL(book.CoverImagePath.String) + ")"))
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(templ.SafeCSS("background-image: url(" + cssURL(book.CoverImagePath.String) + ")"))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 75, Col: 122}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 74, Col: 122}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -225,7 +225,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath.String)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath.String)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 87, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 86, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -238,7 +238,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 88, Col: 25}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 87, Col: 25}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -256,7 +256,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 96, Col: 25}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 95, Col: 25}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -274,7 +274,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 104, Col: 117}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 103, Col: 117}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -292,7 +292,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.Subtitle.String)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.Subtitle.String)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 106, Col: 91}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 105, Col: 91}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -322,7 +322,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var16 templ.SafeURL
|
var templ_7745c5c3_Var16 templ.SafeURL
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs("/bookshelf?author_filter=" + url.QueryEscape(authorName))
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs("/bookshelf?author_filter=" + url.QueryEscape(authorName))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 115, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 114, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -335,7 +335,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(authorName)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(authorName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 115, Col: 154}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 114, Col: 154}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -358,7 +358,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author.String)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author.String)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 120, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 119, Col: 32}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -402,7 +402,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var19 templ.SafeURL
|
var templ_7745c5c3_Var19 templ.SafeURL
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + uuidToString(book.ID))
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + uuidToString(book.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 137, Col: 51}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 136, Col: 51}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -423,7 +423,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.ActionLabel())
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.ActionLabel())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 141, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 140, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -449,7 +449,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.MarkResetLabel())
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.MarkResetLabel())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 145, Col: 137}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 144, Col: 137}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -475,7 +475,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.MarkCompleteLabel())
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.MarkCompleteLabel())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 153, Col: 133}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 152, Col: 133}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -520,7 +520,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(len(book.DeletedAnnotations))
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(len(book.DeletedAnnotations))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 170, Col: 134}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 169, Col: 134}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -551,7 +551,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(book.NotesCount + book.HighlightsCount)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(book.NotesCount + book.HighlightsCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 177, Col: 144}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 176, Col: 144}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -600,7 +600,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64))
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 240, Col: 84}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 239, Col: 84}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -629,7 +629,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var26 templ.SafeURL
|
var templ_7745c5c3_Var26 templ.SafeURL
|
||||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID))
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 250, Col: 125}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 249, Col: 125}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -642,7 +642,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(book.Series.String)
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(book.Series.String)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 254, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 253, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -660,7 +660,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var28 string
|
var templ_7745c5c3_Var28 string
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(book.SeriesNumber.Float64))
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(book.SeriesNumber.Float64))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 256, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 255, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -680,7 +680,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToUpper(book.ReadingDirection.String))
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToUpper(book.ReadingDirection.String))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 261, Col: 142}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 260, Col: 142}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -726,7 +726,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var30 templ.SafeURL
|
var templ_7745c5c3_Var30 templ.SafeURL
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs("/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID))
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs("/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 277, Col: 109}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 276, Col: 109}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -739,7 +739,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var31 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(tag)
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(tag)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 281, Col: 16}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 280, Col: 16}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -767,7 +767,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var32 string
|
var templ_7745c5c3_Var32 string
|
||||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Review.Review)
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Review.Review)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 294, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 293, Col: 61}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -818,7 +818,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var33 string
|
var templ_7745c5c3_Var33 string
|
||||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(book.MetadataNotes.String)
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(book.MetadataNotes.String)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 325, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 324, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -841,7 +841,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var34 string
|
var templ_7745c5c3_Var34 string
|
||||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(book.ProgressHeader())
|
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(book.ProgressHeader())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 331, Col: 95}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 330, Col: 95}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -872,7 +872,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var35 string
|
var templ_7745c5c3_Var35 string
|
||||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100))
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 342, Col: 124}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 341, Col: 124}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -890,7 +890,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var36 string
|
var templ_7745c5c3_Var36 string
|
||||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioCurrentLabel() + " / " + book.AudioDurationLabel())
|
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioCurrentLabel() + " / " + book.AudioDurationLabel())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 350, Col: 144}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 349, Col: 144}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -908,7 +908,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var37 string
|
var templ_7745c5c3_Var37 string
|
||||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100))
|
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 355, Col: 135}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 354, Col: 135}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -927,7 +927,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var38 string
|
var templ_7745c5c3_Var38 string
|
||||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.CurrentPage.Int32)
|
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.CurrentPage.Int32)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 361, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 360, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -940,7 +940,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var39 string
|
var templ_7745c5c3_Var39 string
|
||||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.TotalPages.Int32)
|
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.TotalPages.Int32)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 361, Col: 131}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 360, Col: 131}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -959,7 +959,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var40 string
|
var templ_7745c5c3_Var40 string
|
||||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastReadLabel())
|
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastReadLabel())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 366, Col: 111}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 365, Col: 111}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -972,7 +972,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var41 string
|
var templ_7745c5c3_Var41 string
|
||||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone))
|
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 367, Col: 140}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 366, Col: 140}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -991,7 +991,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var42 string
|
var templ_7745c5c3_Var42 string
|
||||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.LastSyncSource.String)
|
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.LastSyncSource.String)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 373, Col: 110}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 372, Col: 110}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1019,7 +1019,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var43 string
|
var templ_7745c5c3_Var43 string
|
||||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(book.StatsHeader())
|
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(book.StatsHeader())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 382, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 381, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1032,7 +1032,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var44 string
|
var templ_7745c5c3_Var44 string
|
||||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(book.TimeStatLabel())
|
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(book.TimeStatLabel())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 385, Col: 110}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 384, Col: 110}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1050,7 +1050,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var45 string
|
var templ_7745c5c3_Var45 string
|
||||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%dh %dmin", book.BookStats.TotalMinutes/60, book.BookStats.TotalMinutes%60))
|
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%dh %dmin", book.BookStats.TotalMinutes/60, book.BookStats.TotalMinutes%60))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 387, Col: 157}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 386, Col: 157}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1068,7 +1068,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var46 string
|
var templ_7745c5c3_Var46 string
|
||||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d min", book.BookStats.TotalMinutes))
|
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d min", book.BookStats.TotalMinutes))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 389, Col: 119}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 388, Col: 119}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1086,7 +1086,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var47 string
|
var templ_7745c5c3_Var47 string
|
||||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.SessionCount))
|
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.SessionCount))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 394, Col: 114}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 393, Col: 114}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1104,7 +1104,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var48 string
|
var templ_7745c5c3_Var48 string
|
||||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(book.TimeListenedLabel())
|
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(book.TimeListenedLabel())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 399, Col: 106}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 398, Col: 106}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1122,7 +1122,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var49 string
|
var templ_7745c5c3_Var49 string
|
||||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.TotalPages))
|
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.TotalPages))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 404, Col: 113}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 403, Col: 113}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1179,7 +1179,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var50 string
|
var templ_7745c5c3_Var50 string
|
||||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(book.DatePublished.Time.Format("01-02-2006"))
|
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(book.DatePublished.Time.Format("01-02-2006"))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 431, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 430, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1198,7 +1198,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var51 string
|
var templ_7745c5c3_Var51 string
|
||||||
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(book.Isbn.String)
|
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(book.Isbn.String)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 437, Col: 64}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 436, Col: 64}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1257,7 +1257,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var52 string
|
var templ_7745c5c3_Var52 string
|
||||||
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioDurationLabel())
|
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioDurationLabel())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 462, Col: 94}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 461, Col: 94}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1296,7 +1296,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var53 string
|
var templ_7745c5c3_Var53 string
|
||||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(book.PageCount.Int32)
|
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(book.PageCount.Int32)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 480, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 479, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1329,7 +1329,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var54 string
|
var templ_7745c5c3_Var54 string
|
||||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesCount.Int32)
|
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesCount.Int32)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 492, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 491, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1348,7 +1348,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var55 string
|
var templ_7745c5c3_Var55 string
|
||||||
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(book.Volume.Int32)
|
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(book.Volume.Int32)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 498, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 497, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1381,7 +1381,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var56 string
|
var templ_7745c5c3_Var56 string
|
||||||
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(book.CopyrightYear.Int32)
|
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(book.CopyrightYear.Int32)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 510, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 509, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1400,7 +1400,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var57 string
|
var templ_7745c5c3_Var57 string
|
||||||
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ReplaceAll(book.MangaType.String, "_", " "))
|
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ReplaceAll(book.MangaType.String, "_", " "))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 516, Col: 118}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 515, Col: 118}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1419,7 +1419,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var58 string
|
var templ_7745c5c3_Var58 string
|
||||||
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(book.ScanInformation.String)
|
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(book.ScanInformation.String)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 522, Col: 94}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 521, Col: 94}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1438,7 +1438,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var59 string
|
var templ_7745c5c3_Var59 string
|
||||||
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(altSeries)
|
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(altSeries)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 528, Col: 57}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 527, Col: 57}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1457,7 +1457,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var60 string
|
var templ_7745c5c3_Var60 string
|
||||||
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(book.Contributors, ", "))
|
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(book.Contributors, ", "))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 534, Col: 85}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 533, Col: 85}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1475,7 +1475,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var61 string
|
var templ_7745c5c3_Var61 string
|
||||||
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(book.MimeType.String)
|
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(book.MimeType.String)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 539, Col: 67}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 538, Col: 67}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1493,7 +1493,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var62 string
|
var templ_7745c5c3_Var62 string
|
||||||
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(formatFileSize(book.FileSize.Int64))
|
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(formatFileSize(book.FileSize.Int64))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 544, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 543, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1512,7 +1512,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var63 string
|
var templ_7745c5c3_Var63 string
|
||||||
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(book.FileLocation)
|
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(book.FileLocation)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 550, Col: 113}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 549, Col: 113}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1540,7 +1540,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var64 templ.SafeURL
|
var templ_7745c5c3_Var64 templ.SafeURL
|
||||||
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author))
|
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 557, Col: 106}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 556, Col: 106}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1558,7 +1558,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var65 templ.SafeURL
|
var templ_7745c5c3_Var65 templ.SafeURL
|
||||||
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author))
|
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 559, Col: 85}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 558, Col: 85}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1577,7 +1577,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var66 templ.SafeURL
|
var templ_7745c5c3_Var66 templ.SafeURL
|
||||||
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author))
|
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 562, Col: 110}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 561, Col: 110}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1595,7 +1595,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var67 templ.SafeURL
|
var templ_7745c5c3_Var67 templ.SafeURL
|
||||||
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author))
|
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 564, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 563, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1614,7 +1614,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var68 templ.SafeURL
|
var templ_7745c5c3_Var68 templ.SafeURL
|
||||||
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author))
|
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 567, Col: 110}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 566, Col: 110}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1632,7 +1632,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var69 templ.SafeURL
|
var templ_7745c5c3_Var69 templ.SafeURL
|
||||||
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author))
|
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 569, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 568, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1651,7 +1651,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var70 templ.SafeURL
|
var templ_7745c5c3_Var70 templ.SafeURL
|
||||||
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author))
|
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 572, Col: 96}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 571, Col: 96}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1669,7 +1669,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var71 templ.SafeURL
|
var templ_7745c5c3_Var71 templ.SafeURL
|
||||||
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("amazon", "", book.Isbn, book.Title, book.Author))
|
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("amazon", "", book.Isbn, book.Title, book.Author))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 574, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 573, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1688,7 +1688,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var72 templ.SafeURL
|
var templ_7745c5c3_Var72 templ.SafeURL
|
||||||
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinURLErrs(book.WebUrl.String)
|
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinURLErrs(book.WebUrl.String)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 577, Col: 36}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 576, Col: 36}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1701,7 +1701,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var73 string
|
var templ_7745c5c3_Var73 string
|
||||||
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(getDomainName(book.WebUrl.String))
|
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(getDomainName(book.WebUrl.String))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 577, Col: 238}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 576, Col: 238}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1734,7 +1734,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var74 templ.SafeURL
|
var templ_7745c5c3_Var74 templ.SafeURL
|
||||||
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinURLErrs("/collections/" + uuidToString(col.ID))
|
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinURLErrs("/collections/" + uuidToString(col.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 589, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 588, Col: 54}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1747,7 +1747,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var75 string
|
var templ_7745c5c3_Var75 string
|
||||||
templ_7745c5c3_Var75, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("border-color: " + col.Color.String + "; background-color: var(--bg-primary); text-decoration: none;")
|
templ_7745c5c3_Var75, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("border-color: " + col.Color.String + "; background-color: var(--bg-primary); text-decoration: none;")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 591, Col: 118}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 590, Col: 118}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1760,7 +1760,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var76 string
|
var templ_7745c5c3_Var76 string
|
||||||
templ_7745c5c3_Var76, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("color: " + col.Color.String)
|
templ_7745c5c3_Var76, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("color: " + col.Color.String)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 593, Col: 51}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 592, Col: 51}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1773,7 +1773,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var77 string
|
var templ_7745c5c3_Var77 string
|
||||||
templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon.String)
|
templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon.String)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 593, Col: 71}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 592, Col: 71}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1786,7 +1786,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
var templ_7745c5c3_Var78 string
|
var templ_7745c5c3_Var78 string
|
||||||
templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 594, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 593, Col: 61}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1823,8 +1823,16 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, " ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "<!-- Review editor: plain textarea over the RAW markdown (what\n\t\t\t storage and sync carry); rendering happens only on display. --><div x-show=\"reviewOpen\" x-cloak class=\"fixed inset-0 flex items-center justify-center z-50 p-4\" style=\"background-color: var(--surface-overlay);\" @keydown.escape.window=\"reviewOpen = false\"><div class=\"absolute inset-0\" @click=\"reviewOpen = false\"></div><div class=\"relative card p-6 w-full max-w-2xl\"><h3 class=\"text-lg font-semibold mb-3\" style=\"color: var(--text-primary)\">Edit Review</h3><textarea x-model=\"reviewDraft\" rows=\"12\" class=\"w-full rounded-xl border p-3 text-sm font-mono resize-y\" style=\"background-color: var(--bg-primary); border-color: var(--border); color: var(--text-primary);\" placeholder=\"Markdown supported — **bold**, lists, footnotes…\"></textarea><p class=\"mt-1 text-xs\" style=\"color: var(--text-secondary)\">Markdown — renders like notes. Saving an empty review deletes it.</p><div class=\"mt-4 flex items-center gap-2\"><button type=\"button\" @click=\"saveReview()\" :disabled=\"reviewSaving\" class=\"btn btn-primary\"><span x-show=\"!reviewSaving\">Save review</span> <span x-show=\"reviewSaving\">Saving…</span></button> <button type=\"button\" @click=\"deleteReview()\" :disabled=\"reviewSaving || !hasReview\" class=\"btn btn-secondary\" style=\"color: var(--status-danger, #e5484d);\">Delete</button> <button type=\"button\" @click=\"reviewOpen = false\" class=\"btn btn-secondary ml-auto\">Cancel</button></div></div></div>")
|
templ_7745c5c3_Err = WriteToFileConfirmDialog(book).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "<!-- Review editor: plain textarea over the RAW markdown (what\n\t\t\t storage and sync carry); rendering happens only on display. --><div x-show=\"reviewOpen\" x-cloak class=\"fixed inset-0 flex items-center justify-center z-50 p-4\" style=\"background-color: var(--surface-overlay);\" @keydown.escape.window=\"reviewOpen = false\"><div class=\"absolute inset-0\" @click=\"reviewOpen = false\"></div><div class=\"relative card p-6 w-full max-w-2xl\"><h3 class=\"text-lg font-semibold mb-3\" style=\"color: var(--text-primary)\">Edit Review</h3><textarea x-model=\"reviewDraft\" rows=\"12\" class=\"w-full rounded-xl border p-3 text-sm font-mono resize-y\" style=\"background-color: var(--bg-primary); border-color: var(--border); color: var(--text-primary);\" placeholder=\"Markdown supported — **bold**, lists, footnotes…\"></textarea><p class=\"mt-1 text-xs\" style=\"color: var(--text-secondary)\">Markdown — renders like notes. Saving an empty review deletes it.</p><div class=\"mt-4 flex items-center gap-2\"><button type=\"button\" @click=\"saveReview()\" :disabled=\"reviewSaving\" class=\"btn btn-primary\"><span x-show=\"!reviewSaving\">Save review</span> <span x-show=\"reviewSaving\">Saving…</span></button> <button type=\"button\" @click=\"deleteReview()\" :disabled=\"reviewSaving || !hasReview\" class=\"btn btn-secondary\" style=\"color: var(--status-danger, #e5484d);\">Delete</button> <button type=\"button\" @click=\"reviewOpen = false\" class=\"btn btn-secondary ml-auto\">Cancel</button></div></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1832,7 +1840,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "</body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "</body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ templ BookShelf(
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Library - Bookhoard</title>
|
<title>Library - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
@@ -304,7 +303,7 @@ templ BookShelf(
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
<div id="books-grid" class="grid grid-cols-3 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-4">
|
||||||
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
||||||
if errorMessage != "" {
|
if errorMessage != "" {
|
||||||
<div class="col-span-full mt-2 p-4 rounded-xl border" style="background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);">
|
<div class="col-span-full mt-2 p-4 rounded-xl border" style="background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);">
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func BookShelf(
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Library - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Library - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 93, Col: 135}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 92, Col: 135}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -132,7 +132,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 95, Col: 25}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 94, Col: 25}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -195,7 +195,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 152, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -213,7 +213,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -234,7 +234,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -247,7 +247,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 57}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 57}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -260,7 +260,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -278,7 +278,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -291,7 +291,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 48}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 48}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -304,7 +304,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -323,7 +323,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 34}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 164, Col: 34}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -336,7 +336,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 56}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 164, Col: 56}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -349,7 +349,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 76}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 164, Col: 76}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -367,7 +367,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 34}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 166, Col: 34}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -380,7 +380,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 47}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 166, Col: 47}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -393,7 +393,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 67}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 166, Col: 67}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -406,7 +406,7 @@ func BookShelf(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware valueLine truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware valueLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -422,7 +422,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 311, Col: 59}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 310, Col: 59}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -445,7 +445,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 320, Col: 126}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 319, Col: 126}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -476,7 +476,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 329, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 328, Col: 32}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -489,7 +489,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 334, Col: 126}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 333, Col: 126}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// Bundle versions: a short content hash per static JS bundle, set once
|
||||||
|
// at server startup. Script tags render /static/<bundle>.js?v=<hash>,
|
||||||
|
// so any rebuild that changes a bundle's bytes automatically changes
|
||||||
|
// its cache key — browsers fetch the new bundle with no manual stamp
|
||||||
|
// bumps (the manual ?v=N stamp is exactly how a fixed search dropdown
|
||||||
|
// stayed broken for one deploy: the HTML kept pointing at the old,
|
||||||
|
// cached URL).
|
||||||
|
//
|
||||||
|
// An unset bundle renders ?v=0; the /static route also sends
|
||||||
|
// Cache-Control: no-cache, so worst case browsers revalidate — the
|
||||||
|
// hash is the fast path, not the correctness mechanism.
|
||||||
|
|
||||||
|
var bundleVersionsMu sync.RWMutex
|
||||||
|
var bundleVersions = map[string]string{}
|
||||||
|
|
||||||
|
// SetBundleVersion records the content hash for a bundle ("main.js").
|
||||||
|
// Called once at startup for each built bundle.
|
||||||
|
func SetBundleVersion(name, hash string) {
|
||||||
|
bundleVersionsMu.Lock()
|
||||||
|
defer bundleVersionsMu.Unlock()
|
||||||
|
bundleVersions[name] = hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// BundleVersion returns the recorded version for a bundle, or "0".
|
||||||
|
func BundleVersion(name string) string {
|
||||||
|
bundleVersionsMu.RLock()
|
||||||
|
defer bundleVersionsMu.RUnlock()
|
||||||
|
if v, ok := bundleVersions[name]; ok && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MainJSVersion is the templ-side accessor for the main bundle stamp.
|
||||||
|
func MainJSVersion() string {
|
||||||
|
return BundleVersion("main.js")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReaderJSVersion is the templ-side accessor for the reader bundle stamp.
|
||||||
|
func ReaderJSVersion() string {
|
||||||
|
return BundleVersion("reader.js")
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Collection Rules - { collection.Name } - Bookhoard</title>
|
<title>Collection Rules - { collection.Name } - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "')")
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 14, Col: 120}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 13, Col: 120}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -100,7 +100,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: var(--accent-muted); color: " + collection.Color)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: var(--accent-muted); color: " + collection.Color)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 159}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 22, Col: 159}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -113,7 +113,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 179}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 22, Col: 179}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -126,7 +126,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 25, Col: 90}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 24, Col: 90}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -147,7 +147,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 49, Col: 67}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 48, Col: 67}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Collections - Bookhoard</title>
|
<title>Collections - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
@@ -212,13 +211,12 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>{ collection.Name } - Bookhoard</title>
|
<title>{ collection.Name } - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="collections" x-init="initCollectionsPage()" class={ "theme-" + user.Theme }>
|
<body x-data="collections" x-init="initCollectionsPage()" class={ "theme-" + user.Theme }>
|
||||||
@Header(user, "/collections")
|
@Header(user, "/collections")
|
||||||
@LibrarySwitcher(libData, libraryID, "")
|
@LibrarySwitcher(libData, libraryID, "", nil)
|
||||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/collections" class="btn btn-secondary mb-4">
|
<a href="/collections" class="btn btn-secondary mb-4">
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
templ_7745c5c3_Var15 = templ.NopComponent
|
templ_7745c5c3_Var15 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Collections - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Collections - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -395,7 +395,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 159, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 158, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -408,7 +408,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 162, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 161, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -421,7 +421,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 165, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 164, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -444,7 +444,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 171, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 170, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -465,7 +465,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 180, Col: 56}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 56}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -486,7 +486,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 190, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 189, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -499,7 +499,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 191, Col: 81}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 190, Col: 81}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -512,7 +512,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount)))
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 193, Col: 103}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 192, Col: 103}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -577,13 +577,13 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 214, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 213, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -613,7 +613,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = LibrarySwitcher(libData, libraryID, "").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = LibrarySwitcher(libData, libraryID, "", nil).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -632,7 +632,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var30 string
|
var templ_7745c5c3_Var30 string
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: var(--accent-muted); color: " + collection.Color)
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: var(--accent-muted); color: " + collection.Color)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 229, Col: 149}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 227, Col: 149}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -645,7 +645,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var31 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 229, Col: 169}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 227, Col: 169}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -658,7 +658,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var32 string
|
var templ_7745c5c3_Var32 string
|
||||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 232, Col: 25}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 230, Col: 25}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -681,7 +681,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var33 string
|
var templ_7745c5c3_Var33 string
|
||||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 237, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 235, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -709,7 +709,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var34 templ.SafeURL
|
var templ_7745c5c3_Var34 templ.SafeURL
|
||||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs("/collections/" + collection.ID + "/rules")
|
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs("/collections/" + collection.ID + "/rules")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 261, Col: 57}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 259, Col: 57}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -778,7 +778,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var35 string
|
var templ_7745c5c3_Var35 string
|
||||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 339, Col: 93}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 337, Col: 93}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -791,7 +791,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var36 string
|
var templ_7745c5c3_Var36 string
|
||||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
|
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 351, Col: 93}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 349, Col: 93}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -804,7 +804,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var37 string
|
var templ_7745c5c3_Var37 string
|
||||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
|
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 363, Col: 93}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 361, Col: 93}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -825,7 +825,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var38 string
|
var templ_7745c5c3_Var38 string
|
||||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
|
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 383, Col: 90}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 381, Col: 90}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -851,7 +851,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var39 string
|
var templ_7745c5c3_Var39 string
|
||||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 438, Col: 26}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 436, Col: 26}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -864,7 +864,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var40 string
|
var templ_7745c5c3_Var40 string
|
||||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 439, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 437, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -877,7 +877,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var41 string
|
var templ_7745c5c3_Var41 string
|
||||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
|
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 440, Col: 39}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 438, Col: 39}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -890,7 +890,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var42 string
|
var templ_7745c5c3_Var42 string
|
||||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSmart)
|
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSmart)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 441, Col: 37}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 439, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -933,7 +933,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var44 string
|
var templ_7745c5c3_Var44 string
|
||||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
|
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 451, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 449, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -951,7 +951,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var45 string
|
var templ_7745c5c3_Var45 string
|
||||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 455, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 453, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -964,7 +964,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var46 string
|
var templ_7745c5c3_Var46 string
|
||||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 455, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 453, Col: 54}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -982,7 +982,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var47 string
|
var templ_7745c5c3_Var47 string
|
||||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 457, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 455, Col: 61}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1000,7 +1000,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var48 string
|
var templ_7745c5c3_Var48 string
|
||||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 460, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 458, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1013,7 +1013,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var49 string
|
var templ_7745c5c3_Var49 string
|
||||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 465, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 463, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -1026,7 +1026,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var50 string
|
var templ_7745c5c3_Var50 string
|
||||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 472, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 470, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Sync Conflicts - Bookhoard</title>
|
<title>Sync Conflicts - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
templ_7745c5c3_Var15 = templ.NopComponent
|
templ_7745c5c3_Var15 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Sync Conflicts - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Sync Conflicts - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Create Custom Section - Bookhoard</title>
|
<title>Create Custom Section - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Create Custom Section - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Create Custom Section - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 31}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 73, Col: 31}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -100,7 +100,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 73, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
+63
-30
@@ -12,13 +12,12 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Dashboard - Bookhoard</title>
|
<title>Dashboard - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="dashboard" x-init="initDashboard()" class={ "theme-" + user.Theme }>
|
<body x-data="dashboard" x-init="initDashboard()" class={ "theme-" + user.Theme }>
|
||||||
@Header(user, "/dashboard")
|
@Header(user, "/dashboard")
|
||||||
@LibrarySwitcher(libData, currentLibraryID, "Your Library", DashboardActions())
|
@LibrarySwitcher(libData, currentLibraryID, "Your Library", weekWidgetComponent(week, activeLibType), DashboardActions())
|
||||||
<!-- OUTSIDE #collections-container on purpose: the switcher's
|
<!-- OUTSIDE #collections-container on purpose: the switcher's
|
||||||
re-render wipes that container's innerHTML, and anything
|
re-render wipes that container's innerHTML, and anything
|
||||||
server-rendered inside it dies on the first library
|
server-rendered inside it dies on the first library
|
||||||
@@ -41,11 +40,6 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if week != nil {
|
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8 pt-6">
|
|
||||||
@DashboardWeekWidget(*week, activeLibType)
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 pt-5 pb-8">
|
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 pt-5 pb-8">
|
||||||
for _, section := range sections {
|
for _, section := range sections {
|
||||||
@CollectionCarousel(section)
|
@CollectionCarousel(section)
|
||||||
@@ -159,6 +153,25 @@ templ BookCard(item handlers.BookInfo) {
|
|||||||
@Icon(MediaBadgeIcon(item.Badge), "h-4 w-4")
|
@Icon(MediaBadgeIcon(item.Badge), "h-4 w-4")
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
<!-- Viewer progress (2026-10-27 search-progress batch):
|
||||||
|
completed check top-left, partial bar along the cover
|
||||||
|
bottom. Renders only when the feeding adapter loaded
|
||||||
|
the requester's progress (search today). -->
|
||||||
|
if item.ViewerPercentage != nil {
|
||||||
|
if *item.ViewerPercentage >= CompletedProgressThreshold {
|
||||||
|
<span
|
||||||
|
class="absolute top-2 left-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm"
|
||||||
|
style="background-color: color-mix(in srgb, var(--bg-primary) 78%, transparent); color: var(--accent); backdrop-filter: blur(3px);"
|
||||||
|
title="Completed"
|
||||||
|
>
|
||||||
|
@Icon("check", "h-4 w-4")
|
||||||
|
</span>
|
||||||
|
} else if *item.ViewerPercentage > 0 {
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 z-10 h-1" style="background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);">
|
||||||
|
<div class="h-full" style={ progressFillCSS(*item.ViewerPercentage) }></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="book-card-meta">
|
<div class="book-card-meta">
|
||||||
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
||||||
@@ -317,41 +330,56 @@ type BookStatsData struct {
|
|||||||
FirstSession string
|
FirstSession string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DashboardWeekWidget renders the collapsible "This Week" strip. The
|
// weekWidgetComponent adapts the optional week stats into the
|
||||||
// collapsed summary row reads at a glance on every screen size; the
|
// LibrarySwitcher's center slot. nil (stats unavailable or the stats
|
||||||
// expanded body holds the full grid. The details element re-fetches
|
// service is off) = the slot stays empty, exactly as when the widget
|
||||||
// itself through htmx when the body sees a `week-refresh` event
|
// used to be suppressed entirely.
|
||||||
// (dispatched by the library switcher), carrying the CURRENT
|
func weekWidgetComponent(week *WeekStatsData, activeLibType string) templ.Component {
|
||||||
// #library-select value, so the widget tracks the switched library.
|
if week == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return DashboardWeekWidget(*week, activeLibType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardWeekWidget renders the "This Week" stats that live in the
|
||||||
|
// library bar's center slot (LibrarySwitcher's `center` component).
|
||||||
|
// The summary is a compact inline row whose numbers reveal themselves
|
||||||
|
// as width allows; the expanded body is an absolutely positioned
|
||||||
|
// popover that FLOATS over the page content — opening it never shifts
|
||||||
|
// the collections below. The details element re-fetches itself through
|
||||||
|
// htmx when the body sees a `week-refresh` event (dispatched by the
|
||||||
|
// library switcher), carrying the CURRENT #library-select value, so
|
||||||
|
// the widget tracks the switched library. Visible at every width — on
|
||||||
|
// narrow screens the summary collapses to the chart icon + chevron
|
||||||
|
// (tap target, aria-labeled; "This Week" + minutes reveal at `sm`,
|
||||||
|
// pages/sessions/books at md/lg/xl). The panel anchors to the
|
||||||
|
// BAR (full-width under it) below `sm` — the widget is too far left on
|
||||||
|
// phones for widget-centered anchoring — and centers on the widget at
|
||||||
|
// `sm` and up.
|
||||||
templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
|
templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
|
||||||
<details
|
<details
|
||||||
id="week-widget"
|
id="week-widget"
|
||||||
class="card group"
|
class="group sm:relative"
|
||||||
hx-get="/dashboard/week-widget"
|
hx-get="/dashboard/week-widget"
|
||||||
hx-trigger="week-refresh from:body"
|
hx-trigger="week-refresh from:body"
|
||||||
hx-include="#library-select"
|
hx-include="#library-select"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
<summary class="px-4 md:px-6 py-3 flex flex-wrap items-center gap-x-5 gap-y-1 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
|
<summary class="flex items-center gap-2 cursor-pointer select-none list-none rounded-lg px-2 py-1 hover:bg-surface-hover [&::-webkit-details-marker]:hidden" aria-label="Reading stats for this week">
|
||||||
<span class="flex items-center gap-2 font-bold tracking-tight" style="color: var(--text-primary)">
|
<span class="grid place-items-center h-6 w-6 rounded-md shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
<span class="grid place-items-center h-6 w-6 rounded-md" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
||||||
@Icon("chart", "h-4 w-4")
|
@Icon("chart", "h-4 w-4")
|
||||||
</span>
|
</span>
|
||||||
This Week
|
<span class="text-sm font-bold tracking-tight whitespace-nowrap hidden sm:inline" style="color: var(--text-primary)">This Week</span>
|
||||||
</span>
|
<span class="text-sm font-semibold whitespace-nowrap hidden sm:inline" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</span>
|
||||||
<span class="text-sm font-semibold" 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-sm" 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-sm" 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>
|
||||||
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d books", week.WeekBooks) }</span>
|
|
||||||
if week.CurrentStreak > 0 {
|
if week.CurrentStreak > 0 {
|
||||||
<span class="text-sm font-medium" style="color: var(--accent)">🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak</span>
|
<span class="text-xs font-medium whitespace-nowrap hidden lg:inline" style="color: var(--accent)">🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak</span>
|
||||||
}
|
}
|
||||||
<span class="ml-auto flex items-center gap-3">
|
@Icon("chevron-down", "h-4 w-4 shrink-0 transition-transform group-open:rotate-180")
|
||||||
<a href="/stats" class="text-xs font-medium hover:underline" style="color: var(--accent);" @click.stop>Full stats</a>
|
|
||||||
@Icon("chevron-down", "h-4 w-4 transition-transform group-open:rotate-180")
|
|
||||||
</span>
|
|
||||||
</summary>
|
</summary>
|
||||||
<div class="px-4 md:px-6 pb-4 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
|
<div class="card absolute top-full left-4 right-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 mt-2 sm:w-[30rem] max-w-[calc(100vw-2rem)] z-40 p-4 md:px-6 md:py-5" style="box-shadow: var(--shadow-pop);">
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
if activeLibType == "audiobooks" {
|
if activeLibType == "audiobooks" {
|
||||||
<div>
|
<div>
|
||||||
@@ -387,7 +415,8 @@ templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs mt-3" style="color: var(--text-secondary)">
|
<div class="flex items-end justify-between gap-4 mt-3 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)">
|
||||||
Synced from your reading devices
|
Synced from your reading devices
|
||||||
if week.CurrentStreak > 0 {
|
if week.CurrentStreak > 0 {
|
||||||
if week.ActiveToday {
|
if week.ActiveToday {
|
||||||
@@ -397,6 +426,10 @@ templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
<a href="/stats" class="inline-flex items-center gap-1 text-xs font-medium hover:underline shrink-0 whitespace-nowrap" style="color: var(--accent);">Full stats
|
||||||
|
@Icon("arrow-right", "h-4 w-4")
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
|
|||||||
+385
-337
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,6 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Device Management - Bookhoard</title>
|
<title>Device Management - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+21
-21
@@ -31,7 +31,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Device Management - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Device Management - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue("showShelfMappings('" + device.ID.String() + "')")
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue("showShelfMappings('" + device.ID.String() + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 64, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 63, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -154,7 +154,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')")
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 67, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 66, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -175,7 +175,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 102}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 71, Col: 102}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -188,7 +188,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 73, Col: 89}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 89}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -221,7 +221,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 86, Col: 104}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 85, Col: 104}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -249,7 +249,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone))
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 94, Col: 104}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 93, Col: 104}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -277,7 +277,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("sync-url-" + device.ID.String())
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("sync-url-" + device.ID.String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 108, Col: 51}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 107, Col: 51}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -290,7 +290,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 110, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 109, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -303,7 +303,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyFromId('sync-url-" + device.ID.String() + "', 'Kobo sync URL')")
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyFromId('sync-url-" + device.ID.String() + "', 'Kobo sync URL')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 114, Col: 91}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 113, Col: 91}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -330,7 +330,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("auth-token-" + device.ID.String())
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("auth-token-" + device.ID.String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 130, Col: 53}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 129, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -343,7 +343,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 132, Col: 38}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 131, Col: 38}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -356,7 +356,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyFromId('auth-token-" + device.ID.String() + "', 'Auth token')")
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyFromId('auth-token-" + device.ID.String() + "', 'Auth token')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 136, Col: 90}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 135, Col: 90}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -382,7 +382,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token")
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 147, Col: 78}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 146, Col: 78}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -423,7 +423,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 168, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 167, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -436,7 +436,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 169, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -449,7 +449,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 169, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -462,7 +462,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')")
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 174, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 173, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -475,7 +475,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')")
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 177, Col: 71}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 176, Col: 71}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -514,7 +514,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 235, Col: 148}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 234, Col: 148}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -527,7 +527,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')")
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 237, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 236, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<!-- module bundle; ?v=<stamp> busts stale intermediate builds -->
|
<!-- module bundle; ?v=<stamp> busts stale intermediate builds -->
|
||||||
<script type="module" src="/static/main.js?v=7"></script>
|
<script type="module" src={ "/static/main.js?v=" + MainJSVersion() }></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|||||||
+230
-217
@@ -264,7 +264,20 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></main></div><!-- module bundle; ?v=<stamp> busts stale intermediate builds --><script type=\"module\" src=\"/static/main.js?v=7\"></script></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></main></div><!-- module bundle; ?v=<stamp> busts stale intermediate builds --><script type=\"module\" src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 75, Col: 69}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\"></script></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -291,12 +304,12 @@ func DocsTopbar(user User) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var13 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var12 == nil {
|
if templ_7745c5c3_Var13 == nil {
|
||||||
templ_7745c5c3_Var12 = templ.NopComponent
|
templ_7745c5c3_Var13 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<header class=\"sticky top-0 z-30 h-14 shrink-0 border-b flex items-center gap-2 sm:gap-3 px-4 sm:px-6\" style=\"background: var(--bg-primary); border-color: var(--border);\"><button type=\"button\" class=\"lg:hidden icon-btn shrink-0\" @click=\"toggleSidebar()\" aria-label=\"Toggle documentation menu\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<header class=\"sticky top-0 z-30 h-14 shrink-0 border-b flex items-center gap-2 sm:gap-3 px-4 sm:px-6\" style=\"background: var(--bg-primary); border-color: var(--border);\"><button type=\"button\" class=\"lg:hidden icon-btn shrink-0\" @click=\"toggleSidebar()\" aria-label=\"Toggle documentation menu\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -304,7 +317,7 @@ func DocsTopbar(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</button> <a href=\"/docs\" class=\"flex items-center gap-2 shrink-0 no-underline\"><span class=\"grid place-items-center\" style=\"color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</button> <a href=\"/docs\" class=\"flex items-center gap-2 shrink-0 no-underline\"><span class=\"grid place-items-center\" style=\"color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -312,7 +325,7 @@ func DocsTopbar(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span> <span class=\"hidden sm:inline font-bold text-lg tracking-tight\" style=\"color: var(--text-primary);\">Bookhoard</span> <span class=\"text-xs font-semibold px-2 py-0.5 rounded-full\" style=\"background: var(--accent-muted); color: var(--accent);\">Docs</span></a><div class=\"flex-1\"></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span> <span class=\"hidden sm:inline font-bold text-lg tracking-tight\" style=\"color: var(--text-primary);\">Bookhoard</span> <span class=\"text-xs font-semibold px-2 py-0.5 rounded-full\" style=\"background: var(--accent-muted); color: var(--accent);\">Docs</span></a><div class=\"flex-1\"></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -320,7 +333,7 @@ func DocsTopbar(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"hidden md:block h-6 w-px shrink-0\" style=\"background: var(--border);\"></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"hidden md:block h-6 w-px shrink-0\" style=\"background: var(--border);\"></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -328,25 +341,25 @@ func DocsTopbar(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"hidden md:flex items-center gap-2 shrink-0\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"hidden md:flex items-center gap-2 shrink-0\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.ID != "" {
|
if user.ID != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a href=\"/profile\" class=\"icon-btn\" title=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<a href=\"/profile\" class=\"icon-btn\" title=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("Signed in as " + user.Username)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("Signed in as " + user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 109, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 109, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" aria-label=\"Your profile\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" aria-label=\"Your profile\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -354,17 +367,17 @@ func DocsTopbar(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<a href=\"/login\" class=\"text-sm font-medium hover:underline\" style=\"color: var(--text-secondary);\">Sign in</a> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<a href=\"/login\" class=\"text-sm font-medium hover:underline\" style=\"color: var(--text-secondary);\">Sign in</a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<a href=\"/\" class=\"btn btn-primary py-1.5 px-3 text-sm no-underline\">Open App</a></div></header>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a href=\"/\" class=\"btn btn-primary py-1.5 px-3 text-sm no-underline\">Open App</a></div></header>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -391,12 +404,12 @@ func DocsSearchBox() templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var14 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var15 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var14 == nil {
|
if templ_7745c5c3_Var15 == nil {
|
||||||
templ_7745c5c3_Var14 = templ.NopComponent
|
templ_7745c5c3_Var15 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"relative flex-1 max-w-72\" x-data=\"{ open: false }\" @click.outside=\"open = false\" @keydown.escape=\"open = false\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"relative flex-1 max-w-72\" x-data=\"{ open: false }\" @click.outside=\"open = false\" @keydown.escape=\"open = false\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -404,7 +417,7 @@ func DocsSearchBox() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span> <input type=\"text\" class=\"search-input input pl-9 py-1.5 text-sm\" placeholder=\"Search docs…\" id=\"docs-search\" autocomplete=\"off\"><div id=\"docs-search-results\" class=\"hidden absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] max-h-96 overflow-y-auto rounded-2xl border shadow-pop z-40\" style=\"background: var(--bg-secondary); border-color: var(--border);\"></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span> <input type=\"text\" class=\"search-input input pl-9 py-1.5 text-sm\" placeholder=\"Search docs…\" id=\"docs-search\" autocomplete=\"off\"><div id=\"docs-search-results\" class=\"hidden absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] max-h-96 overflow-y-auto rounded-2xl border shadow-pop z-40\" style=\"background: var(--bg-secondary); border-color: var(--border);\"></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -432,84 +445,84 @@ func DocsThemePicker() templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var15 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var16 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var15 == nil {
|
if templ_7745c5c3_Var16 == nil {
|
||||||
templ_7745c5c3_Var15 = templ.NopComponent
|
templ_7745c5c3_Var16 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div x-data=\"theme\" x-init=\"initializeTheme()\" class=\"shrink-0\"><select id=\"theme-select\" @change=\"changeTheme($event.target.value)\" class=\"input py-1.5 pr-8 text-sm cursor-pointer w-auto\" aria-label=\"Theme\"><optgroup label=\"Dark\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<div x-data=\"theme\" x-init=\"initializeTheme()\" class=\"shrink-0\"><select id=\"theme-select\" @change=\"changeTheme($event.target.value)\" class=\"input py-1.5 pr-8 text-sm cursor-pointer w-auto\" aria-label=\"Theme\"><optgroup label=\"Dark\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, opt := range ThemeOptionsFor(ModeDark) {
|
for _, opt := range ThemeOptionsFor(ModeDark) {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<option value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<option value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var16 string
|
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 164, Col: 29}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 164, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 164, Col: 29}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</option>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</optgroup> <optgroup label=\"Light\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
for _, opt := range ThemeOptionsFor(ModeLight) {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<option value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 169, Col: 29}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 164, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</optgroup> <optgroup label=\"Light\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, opt := range ThemeOptionsFor(ModeLight) {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<option value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 169, Col: 29}
|
||||||
|
}
|
||||||
|
_, 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, 46, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 string
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 169, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 169, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</option>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</option>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</optgroup></select></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</optgroup></select></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -536,17 +549,17 @@ func DocsSidebar(nav Navigation, currentPath string) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var20 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var21 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var20 == nil {
|
if templ_7745c5c3_Var21 == nil {
|
||||||
templ_7745c5c3_Var20 = templ.NopComponent
|
templ_7745c5c3_Var21 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<aside id=\"docs-sidebar\" class=\"sidebar fixed lg:sticky top-14 bottom-0 lg:bottom-auto left-0 z-40 lg:z-10 w-72 shrink-0 lg:h-[calc(100vh-3.5rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line\"><nav class=\"py-2\" aria-label=\"Documentation\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<aside id=\"docs-sidebar\" class=\"sidebar fixed lg:sticky top-14 bottom-0 lg:bottom-auto left-0 z-40 lg:z-10 w-72 shrink-0 lg:h-[calc(100vh-3.5rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line\"><nav class=\"py-2\" aria-label=\"Documentation\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, section := range nav.Sections {
|
for _, section := range nav.Sections {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"nav-section border-b border-line last:border-b-0 pb-2\"><button type=\"button\" class=\"nav-section-title w-full flex items-center gap-2.5 px-4 py-2.5 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" @click=\"toggleCollapsible($el)\"><span class=\"shrink-0\" style=\"color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<div class=\"nav-section border-b border-line last:border-b-0 pb-2\"><button type=\"button\" class=\"nav-section-title w-full flex items-center gap-2.5 px-4 py-2.5 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" @click=\"toggleCollapsible($el)\"><span class=\"shrink-0\" style=\"color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -554,33 +567,33 @@ func DocsSidebar(nav Navigation, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span> <span class=\"flex-1 text-left\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span> <span class=\"flex-1 text-left\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var21 string
|
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 196, Col: 52}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span> <span class=\"section-arrow shrink-0 transition-transform\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(chevronStyle(section.Collapsed))
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 197, Col: 103}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 196, Col: 52}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span> <span class=\"section-arrow shrink-0 transition-transform\" style=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(chevronStyle(section.Collapsed))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 197, Col: 103}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -588,101 +601,101 @@ func DocsSidebar(nav Navigation, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span></button><div class=\"nav-items pb-1\" data-collapsed=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</span></button><div class=\"nav-items pb-1\" data-collapsed=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(collapsibleState(section.Collapsed))
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collapsibleState(section.Collapsed))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 201, Col: 85}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 201, Col: 85}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, item := range section.Items {
|
for _, item := range section.Items {
|
||||||
var templ_7745c5c3_Var24 = []any{docsNavClass(currentPath, item.URL, "pl-7")}
|
var templ_7745c5c3_Var25 = []any{docsNavClass(currentPath, item.URL, "pl-7")}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var25...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var25 templ.SafeURL
|
var templ_7745c5c3_Var26 templ.SafeURL
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 203, Col: 25}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 203, Col: 25}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var26 string
|
|
||||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var24).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, 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, 57, "\"><span>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var25).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 204, Col: 26}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span></a> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"><span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, group := range section.Groups {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"mt-0.5\"><button type=\"button\" class=\"nav-group-title w-full flex items-center gap-1.5 pl-7 pr-4 py-1.5 cursor-pointer select-none text-xs font-semibold uppercase tracking-wide transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" @click=\"toggleCollapsible($el)\"><span class=\"flex-1 text-left truncate\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var28 string
|
var templ_7745c5c3_Var28 string
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(group.Title)
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 215, Col: 62}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 204, Col: 26}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</span> <span class=\"section-arrow shrink-0 transition-transform\" style=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span></a> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, group := range section.Groups {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div class=\"mt-0.5\"><button type=\"button\" class=\"nav-group-title w-full flex items-center gap-1.5 pl-7 pr-4 py-1.5 cursor-pointer select-none text-xs font-semibold uppercase tracking-wide transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" @click=\"toggleCollapsible($el)\"><span class=\"flex-1 text-left truncate\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(chevronStyle(group.Collapsed))
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(group.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 216, Col: 104}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 215, Col: 62}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span> <span class=\"section-arrow shrink-0 transition-transform\" style=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var30 string
|
||||||
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(chevronStyle(group.Collapsed))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 216, Col: 104}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -690,74 +703,69 @@ func DocsSidebar(nav Navigation, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></button><div class=\"nav-group-items\" data-collapsed=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</span></button><div class=\"nav-group-items\" data-collapsed=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var30 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(collapsibleState(group.Collapsed))
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(collapsibleState(group.Collapsed))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 220, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 220, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, item := range group.Items {
|
for _, item := range group.Items {
|
||||||
var templ_7745c5c3_Var31 = []any{docsNavClass(currentPath, item.URL, "pl-9")}
|
var templ_7745c5c3_Var32 = []any{docsNavClass(currentPath, item.URL, "pl-9")}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var31...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var32 templ.SafeURL
|
var templ_7745c5c3_Var33 templ.SafeURL
|
||||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 222, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 222, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var33 string
|
|
||||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var31).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\"><span class=\"truncate\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var34 string
|
var templ_7745c5c3_Var34 string
|
||||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var32).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, 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, 67, "\"><span class=\"truncate\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var35 string
|
||||||
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 223, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 223, Col: 46}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span></a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</span></a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</div></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -767,7 +775,12 @@ func DocsSidebar(nav Navigation, currentPath string) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</nav></aside>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</nav></aside>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -794,77 +807,77 @@ func DocsSearchResults(results []SearchResultItem) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var35 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var36 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var35 == nil {
|
if templ_7745c5c3_Var36 == nil {
|
||||||
templ_7745c5c3_Var35 = templ.NopComponent
|
templ_7745c5c3_Var36 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div class=\"px-4 py-3 text-sm\" style=\"color: var(--text-secondary);\">No matching pages.</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<div class=\"px-4 py-3 text-sm\" style=\"color: var(--text-secondary);\">No matching pages.</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<ul class=\"py-1 list-none m-0 p-0\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<ul class=\"py-1 list-none m-0 p-0\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<li><a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<li><a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var36 templ.SafeURL
|
var templ_7745c5c3_Var37 templ.SafeURL
|
||||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(r.URL)
|
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(r.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 246, Col: 20}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 246, Col: 20}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" class=\"block px-4 py-2 no-underline transition-colors hover:bg-surface-hover\" @click=\"closeSearchResults($event)\"><span class=\"block text-sm font-medium truncate\" style=\"color: var(--text-primary);\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var37 string
|
|
||||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 247, Col: 100}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" class=\"block px-4 py-2 no-underline transition-colors hover:bg-surface-hover\" @click=\"closeSearchResults($event)\"><span class=\"block text-sm font-medium truncate\" style=\"color: var(--text-primary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if r.Category != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"block text-xs\" style=\"color: var(--text-secondary);\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var38 string
|
var templ_7745c5c3_Var38 string
|
||||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Category)
|
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 249, Col: 85}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 247, Col: 100}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if r.Category != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"block text-xs\" style=\"color: var(--text-secondary);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var39 string
|
||||||
|
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(r.Category)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 249, Col: 85}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</a></li>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</a></li>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</ul>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</ul>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -891,12 +904,12 @@ func DocsNotFound(title string, message string) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var39 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var39 == nil {
|
if templ_7745c5c3_Var40 == nil {
|
||||||
templ_7745c5c3_Var39 = templ.NopComponent
|
templ_7745c5c3_Var40 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<!doctype html><html lang=\"en\"><head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<!doctype html><html lang=\"en\"><head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -904,20 +917,20 @@ func DocsNotFound(title string, message string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var40 string
|
var templ_7745c5c3_Var41 string
|
||||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 267, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 267, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, " - Bookhoard Documentation</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"min-h-screen font-sans antialiased\"><div class=\"min-h-screen flex items-center justify-center p-4\"><div class=\"card p-8 max-w-md w-full text-center\"><span class=\"inline-grid place-items-center mb-4\" style=\"color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " - Bookhoard Documentation</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"min-h-screen font-sans antialiased\"><div class=\"min-h-screen flex items-center justify-center p-4\"><div class=\"card p-8 max-w-md w-full text-center\"><span class=\"inline-grid place-items-center mb-4\" style=\"color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -925,33 +938,33 @@ func DocsNotFound(title string, message string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span><h1 class=\"text-2xl font-bold mb-3\" style=\"color: var(--text-primary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</span><h1 class=\"text-2xl font-bold mb-3\" style=\"color: var(--text-primary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var41 string
|
|
||||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 277, Col: 84}
|
|
||||||
}
|
|
||||||
_, 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, 84, "</h1><p class=\"text-sm mb-6\" style=\"color: var(--text-secondary);\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var42 string
|
var templ_7745c5c3_Var42 string
|
||||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 278, Col: 76}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 277, Col: 84}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</p><div class=\"flex items-center justify-center gap-3\"><a href=\"/docs\" class=\"btn btn-primary py-2 px-4 text-sm no-underline\">Documentation</a> <a href=\"/\" class=\"btn py-2 px-4 text-sm no-underline\">Bookhoard</a></div></div></div></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</h1><p class=\"text-sm mb-6\" style=\"color: var(--text-secondary);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var43 string
|
||||||
|
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 278, Col: 76}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</p><div class=\"flex items-center justify-center gap-3\"><a href=\"/docs\" class=\"btn btn-primary py-2 px-4 text-sm no-underline\">Documentation</a> <a href=\"/\" class=\"btn py-2 px-4 text-sm no-underline\">Bookhoard</a></div></div></div></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-4
@@ -4,6 +4,12 @@ templ Header(user User, currentPath string) {
|
|||||||
<div
|
<div
|
||||||
x-data="header"
|
x-data="header"
|
||||||
>
|
>
|
||||||
|
<!-- htmx loads with the header (classic script, before the module
|
||||||
|
bundle): the header's live search is htmx-driven on EVERY
|
||||||
|
page, so the include lives HERE — not per-page. Pages that
|
||||||
|
render @Header must not include htmx.min.js themselves
|
||||||
|
(double processing). -->
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
<!-- Mobile backdrop -->
|
<!-- Mobile backdrop -->
|
||||||
<div
|
<div
|
||||||
class="app-sidebar-backdrop lg:hidden"
|
class="app-sidebar-backdrop lg:hidden"
|
||||||
@@ -224,23 +230,46 @@ templ Header(user User, currentPath string) {
|
|||||||
>
|
>
|
||||||
@Icon("menu", "h-5 w-5")
|
@Icon("menu", "h-5 w-5")
|
||||||
</button>
|
</button>
|
||||||
<div class="relative flex-1 max-w-xl">
|
<div class="relative flex-1 max-w-xl" x-data="headerSearch" @click.outside="close()">
|
||||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
|
||||||
@Icon("search", "h-4 w-4")
|
@Icon("search", "h-4 w-4")
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="header-search"
|
id="header-search"
|
||||||
|
name="q"
|
||||||
placeholder="Search your library…"
|
placeholder="Search your library…"
|
||||||
class="input pl-10"
|
class="input pl-10"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
x-ref="input"
|
||||||
|
role="combobox"
|
||||||
|
:aria-expanded="open ? 'true' : 'false'"
|
||||||
|
aria-label="Search your library"
|
||||||
|
@input="onInput()"
|
||||||
|
@focus="onFocus()"
|
||||||
|
@keydown.arrow-down.prevent="moveSelection(1)"
|
||||||
|
@keydown.arrow-up.prevent="moveSelection(-1)"
|
||||||
|
@keydown.enter.prevent="openSelected()"
|
||||||
|
@keydown.escape="close()"
|
||||||
|
hx-get="/api/media-items/search?layout=dropdown"
|
||||||
|
hx-trigger="search"
|
||||||
|
hx-target="#search-results"
|
||||||
|
hx-swap="innerHTML"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
id="search-results"
|
||||||
|
x-show="open"
|
||||||
|
x-cloak
|
||||||
|
class="absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden"
|
||||||
|
style="background-color: var(--bg-secondary); border-color: var(--border)"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate
|
<!-- module bundle; ?v=<content hash> busts stale intermediate
|
||||||
builds across app restarts (browser caches are keyed by URL) -->
|
builds across app restarts (browser caches are keyed by URL);
|
||||||
<script type="module" src="/static/main.js?v=7"></script>
|
the hash is computed at startup from the served file -->
|
||||||
|
<script type="module" src={ "/static/main.js?v=" + MainJSVersion() }></script>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+56
-43
@@ -29,7 +29,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"header\"><!-- Mobile backdrop --><div class=\"app-sidebar-backdrop lg:hidden\" :class=\"{ 'is-open': mobileMenuOpen }\" @click=\"mobileMenuOpen = false\" x-cloak></div><!-- Sidebar --><aside class=\"app-sidebar\" :class=\"{ 'is-open': mobileMenuOpen }\"><!-- Logo --><a href=\"/dashboard\" class=\"flex items-center gap-2.5 px-5 h-16 shrink-0 border-b\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><span class=\"grid place-items-center h-6 w-6 shrink-0\" style=\"color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"header\"><!-- htmx loads with the header (classic script, before the module\n\t\t bundle): the header's live search is htmx-driven on EVERY\n\t\t page, so the include lives HERE — not per-page. Pages that\n\t\t render @Header must not include htmx.min.js themselves\n\t\t (double processing). --><script src=\"/static/htmx.min.js\"></script><!-- Mobile backdrop --><div class=\"app-sidebar-backdrop lg:hidden\" :class=\"{ 'is-open': mobileMenuOpen }\" @click=\"mobileMenuOpen = false\" x-cloak></div><!-- Sidebar --><aside class=\"app-sidebar\" :class=\"{ 'is-open': mobileMenuOpen }\"><!-- Logo --><a href=\"/dashboard\" class=\"flex items-center gap-2.5 px-5 h-16 shrink-0 border-b\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><span class=\"grid place-items-center h-6 w-6 shrink-0\" style=\"color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -254,7 +254,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("sidebarShell('" + string(ThemeModeOf(user.Theme)) + "')")
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("sidebarShell('" + string(ThemeModeOf(user.Theme)) + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 86, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 92, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -298,7 +298,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 115, Col: 104}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 121, Col: 104}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -319,7 +319,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeDark)))
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeDark)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 119, Col: 78}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 125, Col: 78}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -332,7 +332,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 125, Col: 105}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 131, Col: 105}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -353,7 +353,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeLight)))
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeLight)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 129, Col: 79}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 135, Col: 79}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -371,7 +371,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === '" + string(opt.Mode) + "'")
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === '" + string(opt.Mode) + "'")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 139, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 145, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -384,7 +384,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 140, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 146, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -397,7 +397,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(opt.Mode))
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(opt.Mode))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 141, Col: 37}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 147, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -410,7 +410,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 144, Col: 131}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 150, Col: 131}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -423,7 +423,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 145, Col: 51}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 151, Col: 51}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -476,7 +476,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var28 string
|
var templ_7745c5c3_Var28 string
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 157, Col: 55}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 163, Col: 55}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -489,7 +489,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 159, Col: 26}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 165, Col: 26}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -512,7 +512,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var30 string
|
var templ_7745c5c3_Var30 string
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 165, Col: 191}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 171, Col: 191}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -530,7 +530,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var31 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 167, Col: 48}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 173, Col: 48}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -717,7 +717,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</button><div class=\"relative flex-1 max-w-xl\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</button><div class=\"relative flex-1 max-w-xl\" x-data=\"headerSearch\" @click.outside=\"close()\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -725,7 +725,20 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL) --><script type=\"module\" src=\"/static/main.js?v=7\"></script></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <input type=\"text\" id=\"header-search\" name=\"q\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\" x-ref=\"input\" role=\"combobox\" :aria-expanded=\"open ? 'true' : 'false'\" aria-label=\"Search your library\" @input=\"onInput()\" @focus=\"onFocus()\" @keydown.arrow-down.prevent=\"moveSelection(1)\" @keydown.arrow-up.prevent=\"moveSelection(-1)\" @keydown.enter.prevent=\"openSelected()\" @keydown.escape=\"close()\" hx-get=\"/api/media-items/search?layout=dropdown\" hx-trigger=\"search\" hx-target=\"#search-results\" hx-swap=\"innerHTML\"><div id=\"search-results\" x-show=\"open\" x-cloak class=\"absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"></div></div></div></header><!-- module bundle; ?v=<content hash> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL);\n\t\t the hash is computed at startup from the served file --><script type=\"module\" src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var42 string
|
||||||
|
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 272, Col: 68}
|
||||||
|
}
|
||||||
|
_, 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, 73, "\"></script></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -750,12 +763,12 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var42 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var43 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var42 == nil {
|
if templ_7745c5c3_Var43 == nil {
|
||||||
templ_7745c5c3_Var42 = templ.NopComponent
|
templ_7745c5c3_Var43 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -763,20 +776,20 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span> <span class=\"flex-1 text-left truncate\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span> <span class=\"flex-1 text-left truncate\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var43 string
|
var templ_7745c5c3_Var44 string
|
||||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 259, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 288, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -784,7 +797,7 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -792,7 +805,7 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -800,7 +813,7 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span>Logout</span></button></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span>Logout</span></button></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -826,12 +839,12 @@ func SidebarSignIn(currentPath string) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var44 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var45 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var44 == nil {
|
if templ_7745c5c3_Var45 == nil {
|
||||||
templ_7745c5c3_Var44 = templ.NopComponent
|
templ_7745c5c3_Var45 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -839,7 +852,7 @@ func SidebarSignIn(currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -847,30 +860,30 @@ func SidebarSignIn(currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var45 string
|
var templ_7745c5c3_Var46 string
|
||||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 307, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 336, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if SignupVisible() {
|
if SignupVisible() {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, actions ...templ.Component) {
|
// LibrarySwitcher renders the sticky subbar: title + library select on
|
||||||
|
// the left (title hidden below `sm` — phones need the row width for the
|
||||||
|
// select + actions + the dashboard's stats widget), optional action
|
||||||
|
// buttons on the right. `center` is an
|
||||||
|
// optional slot between them (nil = unused) — the dashboard passes its
|
||||||
|
// "This Week" stats widget here so it participates in the bar's
|
||||||
|
// justify-between layout as the middle group.
|
||||||
|
templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, center templ.Component, actions ...templ.Component) {
|
||||||
<div
|
<div
|
||||||
class="app-subbar backdrop-blur border-b"
|
class="app-subbar backdrop-blur border-b"
|
||||||
style="background-color: color-mix(in srgb, var(--bg-primary) 88%, transparent); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);"
|
style="background-color: color-mix(in srgb, var(--bg-primary) 88%, transparent); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);"
|
||||||
@@ -8,7 +15,7 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
|
|||||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-3 flex items-center justify-between gap-4">
|
<div class="w-full px-4 sm:px-6 lg:px-8 py-3 flex items-center justify-between gap-4">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
if title != "" {
|
if title != "" {
|
||||||
<span class="text-base font-bold tracking-tight shrink-0" style="color: var(--text-primary)">{ title }</span>
|
<span class="text-base font-bold tracking-tight shrink-0 hidden sm:inline" style="color: var(--text-primary)">{ title }</span>
|
||||||
} else {
|
} else {
|
||||||
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library</label>
|
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library</label>
|
||||||
}
|
}
|
||||||
@@ -33,6 +40,9 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
if center != nil {
|
||||||
|
@center
|
||||||
|
}
|
||||||
if len(actions) > 0 {
|
if len(actions) > 0 {
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
for _, action := range actions {
|
for _, action := range actions {
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ package templates
|
|||||||
import "github.com/a-h/templ"
|
import "github.com/a-h/templ"
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, actions ...templ.Component) templ.Component {
|
// LibrarySwitcher renders the sticky subbar: title + library select on
|
||||||
|
// the left (title hidden below `sm` — phones need the row width for the
|
||||||
|
// select + actions + the dashboard's stats widget), optional action
|
||||||
|
// buttons on the right. `center` is an
|
||||||
|
// optional slot between them (nil = unused) — the dashboard passes its
|
||||||
|
// "This Week" stats widget here so it participates in the bar's
|
||||||
|
// justify-between layout as the middle group.
|
||||||
|
func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, center templ.Component, actions ...templ.Component) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -34,14 +41,14 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if title != "" {
|
if title != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"text-base font-bold tracking-tight shrink-0\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"text-base font-bold tracking-tight shrink-0 hidden sm:inline\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 11, Col: 105}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 18, Col: 122}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -64,7 +71,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 20, Col: 62}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 27, Col: 62}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -84,7 +91,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 24, Col: 29}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 29}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -97,7 +104,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 24, Col: 51}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 51}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -110,7 +117,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 24, Col: 71}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 71}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -128,7 +135,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 26, Col: 29}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 29}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -141,7 +148,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 26, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -154,7 +161,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 26, Col: 62}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 62}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -173,7 +180,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 29, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -186,7 +193,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 29, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -199,7 +206,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 29, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -217,7 +224,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -230,7 +237,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -243,7 +250,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 61}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -259,6 +266,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
if center != nil {
|
||||||
|
templ_7745c5c3_Err = center.Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(actions) > 0 {
|
if len(actions) > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"flex items-center gap-2\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"flex items-center gap-2\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ templ Profile(user User) {
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Profile Settings - Bookhoard</title>
|
<title>Profile Settings - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func Profile(user User) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Profile Settings - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Profile Settings - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Reading Progress - Bookhoard</title>
|
<title>Reading Progress - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+17
-17
@@ -32,7 +32,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Reading Progress - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Reading Progress - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 44, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 43, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -109,7 +109,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var5 templ.SafeURL
|
var templ_7745c5c3_Var5 templ.SafeURL
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID.String())
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID.String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 54, Col: 56}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 53, Col: 56}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -122,7 +122,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 58, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 57, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -140,7 +140,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 61, Col: 91}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 60, Col: 91}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -158,7 +158,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", item.ProgressPercentage))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", item.ProgressPercentage))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 64, Col: 127}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 63, Col: 127}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -171,7 +171,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width: " + fmt.Sprintf("%.1f", item.ProgressPercentage) + "%; background-color: var(--accent);")
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width: " + fmt.Sprintf("%.1f", item.ProgressPercentage) + "%; background-color: var(--accent);")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 68, Col: 162}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 67, Col: 162}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -185,7 +185,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmtSecondsHMS(item.ProgressPercentage/100*item.DurationSeconds) + " / " + fmtSecondsHMS(item.DurationSeconds))
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmtSecondsHMS(item.ProgressPercentage/100*item.DurationSeconds) + " / " + fmtSecondsHMS(item.DurationSeconds))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 76, Col: 124}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 75, Col: 124}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -196,7 +196,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Page %d of %d (est.)", item.CurrentPage, item.EstimatedPages))
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Page %d of %d (est.)", item.CurrentPage, item.EstimatedPages))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 79, Col: 90}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 78, Col: 90}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -206,7 +206,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f%%", item.ProgressPercentage))
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f%%", item.ProgressPercentage))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 81, Col: 62}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 80, Col: 62}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -217,7 +217,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", item.CurrentPage, item.TotalPages))
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", item.CurrentPage, item.TotalPages))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 84, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 83, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -231,7 +231,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.LastUpdated)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.LastUpdated)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 90, Col: 95}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 89, Col: 95}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -244,7 +244,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceIcon)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceIcon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 95, Col: 51}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 94, Col: 51}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -257,7 +257,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 96, Col: 98}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 95, Col: 98}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -283,7 +283,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 103, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 102, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -296,7 +296,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceType)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceType)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 104, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 103, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -323,7 +323,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 110, Col: 36}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 109, Col: 36}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
<link rel="manifest" href="/static/manifest.json"/>
|
<link rel="manifest" href="/static/manifest.json"/>
|
||||||
<link href="/static/reader-fonts.css" rel="stylesheet"/>
|
<link href="/static/reader-fonts.css" rel="stylesheet"/>
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script type="module" src="/static/reader.js"></script>
|
<script type="module" src={ "/static/reader.js?v=" + ReaderJSVersion() }></script>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+76
-63
@@ -65,42 +65,55 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard Reader</title><link rel=\"manifest\" href=\"/static/manifest.json\"><link href=\"/static/reader-fonts.css\" rel=\"stylesheet\"><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/reader.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard Reader</title><link rel=\"manifest\" href=\"/static/manifest.json\"><link href=\"/static/reader-fonts.css\" rel=\"stylesheet\"><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " h-screen overflow-hidden overscroll-none"}
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/reader.js?v=" + ReaderJSVersion())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 36, Col: 73}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<body x-data=\"readerShell\" x-init=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 = []any{"theme-" + user.Theme + " h-screen overflow-hidden overscroll-none"}
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(readerInitExpr(metadata))
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 42, Col: 36}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<body x-data=\"readerShell\" x-init=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(readerInitExpr(metadata))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 42, Col: 36}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><!-- Reading surface: edge-to-edge. Chrome overlays translucently;\n\t\t\t drawers and scrim float above everything. --><div id=\"reader-viewport\" class=\"fixed inset-0\"><foliate-view id=\"reader-view\" exportparts=\"filter\" class=\"block w-full h-full\"></foliate-view><!-- Desktop edge page-turn zones: hover to reveal the arrow.\n\t\t\t\t Hidden on touch (tap zones cover that) and while a\n\t\t\t\t fixed-layout page is zoomed (edges belong to content then). --><div class=\"reader-edge-zone left\" :class=\"edgeZonesActive ? '' : 'pointer-events-none'\" @click=\"goLeft()\" aria-hidden=\"true\"><svg class=\"reader-icon\" width=\"28\" height=\"28\" aria-hidden=\"true\"><path d=\"M 18 6 L 10 14 L 18 22\"></path></svg></div><div class=\"reader-edge-zone right\" :class=\"edgeZonesActive ? '' : 'pointer-events-none'\" @click=\"goRight()\" aria-hidden=\"true\"><svg class=\"reader-icon\" width=\"28\" height=\"28\" aria-hidden=\"true\"><path d=\"M 10 6 L 18 14 L 10 22\"></path></svg></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"><!-- Reading surface: edge-to-edge. Chrome overlays translucently;\n\t\t\t drawers and scrim float above everything. --><div id=\"reader-viewport\" class=\"fixed inset-0\"><foliate-view id=\"reader-view\" exportparts=\"filter\" class=\"block w-full h-full\"></foliate-view><!-- Desktop edge page-turn zones: hover to reveal the arrow.\n\t\t\t\t Hidden on touch (tap zones cover that) and while a\n\t\t\t\t fixed-layout page is zoomed (edges belong to content then). --><div class=\"reader-edge-zone left\" :class=\"edgeZonesActive ? '' : 'pointer-events-none'\" @click=\"goLeft()\" aria-hidden=\"true\"><svg class=\"reader-icon\" width=\"28\" height=\"28\" aria-hidden=\"true\"><path d=\"M 18 6 L 10 14 L 18 22\"></path></svg></div><div class=\"reader-edge-zone right\" :class=\"edgeZonesActive ? '' : 'pointer-events-none'\" @click=\"goRight()\" aria-hidden=\"true\"><svg class=\"reader-icon\" width=\"28\" height=\"28\" aria-hidden=\"true\"><path d=\"M 10 6 L 18 14 L 10 22\"></path></svg></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -108,7 +121,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Drawer scrim --><div x-show=\"tocOpen || settingsOpen || bookmarksOpen || searchOpen\" x-transition.opacity.duration.200ms @click=\"closeDrawers()\" class=\"drawer-scrim\" aria-hidden=\"true\"></div><!-- TOC drawer (left) --><div id=\"toc-drawer\" x-show=\"tocOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"-translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"-translate-x-full\" class=\"reader-drawer left\" role=\"dialog\" aria-label=\"Table of contents\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<!-- Drawer scrim --><div x-show=\"tocOpen || settingsOpen || bookmarksOpen || searchOpen\" x-transition.opacity.duration.200ms @click=\"closeDrawers()\" class=\"drawer-scrim\" aria-hidden=\"true\"></div><!-- TOC drawer (left) --><div id=\"toc-drawer\" x-show=\"tocOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"-translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"-translate-x-full\" class=\"reader-drawer left\" role=\"dialog\" aria-label=\"Table of contents\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -116,7 +129,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><!-- Settings drawer (right) --><div id=\"settings-drawer\" x-show=\"settingsOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"reader-drawer right\" role=\"dialog\" aria-label=\"Reader settings\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><!-- Settings drawer (right) --><div id=\"settings-drawer\" x-show=\"settingsOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"reader-drawer right\" role=\"dialog\" aria-label=\"Reader settings\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -124,7 +137,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><!-- Selection popover (highlights) --><div id=\"selection-popover\" x-show=\"selectionPopover.open\" x-transition:enter=\"transition ease-out duration-150\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-100\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" :style=\"'left:' + selectionPopover.x + 'px; top:' + selectionPopover.y + 'px'\" class=\"reader-popover\" role=\"dialog\" aria-label=\"Highlight selection\" @pointerdown.stop><div class=\"flex items-center gap-2\"><template x-for=\"c in highlightColors\" :key=\"c\"><button type=\"button\" class=\"color-dot\" :class=\"selectionPopover.color === c ? 'selected' : ''\" :style=\"'background-color:' + c\" :title=\"selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'\" :aria-label=\"'Highlight color ' + c\" @click=\"pickHighlightColor(c)\"></button></template><div class=\"w-px h-5 reader-sep\"></div><button type=\"button\" class=\"reader-popover-btn\" title=\"Note\" @click=\"openSelectionNote()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5\"></path></svg></button> <button type=\"button\" class=\"reader-popover-btn\" title=\"Copy text\" @click=\"copySelectionText()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 6 6 V 3 H 16 V 13 H 13 M 3 6 H 13 V 16 H 3 Z\"></path></svg></button><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn\" title=\"Adjust highlight extent\" @click=\"startAnyAdjust()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 3 v 18 M 19 3 v 18 M 5 8 h 14 M 5 16 h 14\"></path></svg></button></template><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn danger\" title=\"Delete highlight\" @click=\"deleteHighlightById(selectionPopover.id)\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 6 H 15 L 14 17 H 6 Z M 8 6 V 4 H 12 V 6 M 4 6 H 16\"></path></svg></button></template></div><div x-show=\"selectionPopover.noteOpen\" class=\"mt-2\"><textarea x-model=\"selectionPopover.note\" rows=\"3\" class=\"reader-note-input\" placeholder=\"Note…\" @paste=\"richNotePaste\"></textarea><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">Markdown supported — rich-text paste converts to markdown</p><div class=\"text-xs mt-0.5 text-right\" x-show=\"selectionPopover.note.length > 0\" :style=\"noteOverLimit(selectionPopover.note) ? 'color: #f87171;' : 'color: var(--text-secondary);'\" x-text=\"noteCountLabel(selectionPopover.note)\"></div><div class=\"flex gap-2 mt-1\"><button type=\"button\" class=\"flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\" :disabled=\"noteOverLimit(selectionPopover.note)\" @click=\"selectionPopover.mode === 'create' ? createHighlight(selectionPopover.color) : saveHighlightChanges()\" x-text=\"selectionPopover.mode === 'create' ? 'Highlight with note' : 'Save note'\">Save</button> <button type=\"button\" class=\"flex-1 py-1 text-xs border rounded hover:opacity-80\" style=\"border-color: var(--border);\" @click=\"hideSelectionPopover()\">Cancel</button></div></div></div><!-- Annotations drawer (right): highlights / notes / bookmarks --><div id=\"annotations-drawer\" x-show=\"bookmarksOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"reader-drawer right\" role=\"dialog\" aria-label=\"Annotations\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><!-- Selection popover (highlights) --><div id=\"selection-popover\" x-show=\"selectionPopover.open\" x-transition:enter=\"transition ease-out duration-150\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-100\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" :style=\"'left:' + selectionPopover.x + 'px; top:' + selectionPopover.y + 'px'\" class=\"reader-popover\" role=\"dialog\" aria-label=\"Highlight selection\" @pointerdown.stop><div class=\"flex items-center gap-2\"><template x-for=\"c in highlightColors\" :key=\"c\"><button type=\"button\" class=\"color-dot\" :class=\"selectionPopover.color === c ? 'selected' : ''\" :style=\"'background-color:' + c\" :title=\"selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'\" :aria-label=\"'Highlight color ' + c\" @click=\"pickHighlightColor(c)\"></button></template><div class=\"w-px h-5 reader-sep\"></div><button type=\"button\" class=\"reader-popover-btn\" title=\"Note\" @click=\"openSelectionNote()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5\"></path></svg></button> <button type=\"button\" class=\"reader-popover-btn\" title=\"Copy text\" @click=\"copySelectionText()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 6 6 V 3 H 16 V 13 H 13 M 3 6 H 13 V 16 H 3 Z\"></path></svg></button><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn\" title=\"Adjust highlight extent\" @click=\"startAnyAdjust()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 3 v 18 M 19 3 v 18 M 5 8 h 14 M 5 16 h 14\"></path></svg></button></template><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn danger\" title=\"Delete highlight\" @click=\"deleteHighlightById(selectionPopover.id)\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 6 H 15 L 14 17 H 6 Z M 8 6 V 4 H 12 V 6 M 4 6 H 16\"></path></svg></button></template></div><div x-show=\"selectionPopover.noteOpen\" class=\"mt-2\"><textarea x-model=\"selectionPopover.note\" rows=\"3\" class=\"reader-note-input\" placeholder=\"Note…\" @paste=\"richNotePaste\"></textarea><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">Markdown supported — rich-text paste converts to markdown</p><div class=\"text-xs mt-0.5 text-right\" x-show=\"selectionPopover.note.length > 0\" :style=\"noteOverLimit(selectionPopover.note) ? 'color: #f87171;' : 'color: var(--text-secondary);'\" x-text=\"noteCountLabel(selectionPopover.note)\"></div><div class=\"flex gap-2 mt-1\"><button type=\"button\" class=\"flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\" :disabled=\"noteOverLimit(selectionPopover.note)\" @click=\"selectionPopover.mode === 'create' ? createHighlight(selectionPopover.color) : saveHighlightChanges()\" x-text=\"selectionPopover.mode === 'create' ? 'Highlight with note' : 'Save note'\">Save</button> <button type=\"button\" class=\"flex-1 py-1 text-xs border rounded hover:opacity-80\" style=\"border-color: var(--border);\" @click=\"hideSelectionPopover()\">Cancel</button></div></div></div><!-- Annotations drawer (right): highlights / notes / bookmarks --><div id=\"annotations-drawer\" x-show=\"bookmarksOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"reader-drawer right\" role=\"dialog\" aria-label=\"Annotations\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -132,7 +145,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><!-- Search drawer (right, reflowable only) --><div id=\"search-drawer\" x-show=\"searchOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"reader-drawer right\" role=\"dialog\" aria-label=\"Search\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div><!-- Search drawer (right, reflowable only) --><div id=\"search-drawer\" x-show=\"searchOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"reader-drawer right\" role=\"dialog\" aria-label=\"Search\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -140,7 +153,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div><!-- Shortcuts & gestures help modal --><div x-show=\"helpOpen\" x-transition.opacity.duration.200ms class=\"reader-help-backdrop\" @click=\"helpOpen = false\" aria-hidden=\"true\"></div><div x-show=\"helpOpen\" x-transition:enter=\"transition ease-out duration-150\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-100\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"reader-help\" role=\"dialog\" aria-label=\"Shortcuts and gestures\"><div class=\"reader-help-header\"><h3 class=\"font-semibold text-base\">Shortcuts & Gestures</h3><button @click=\"helpOpen = false\" class=\"p-1.5 rounded-lg hover:bg-gray-700\" title=\"Close (Esc)\" aria-label=\"Close\">✕</button></div><div class=\"reader-help-body\"><h4 class=\"reader-help-section\">Navigation</h4><div class=\"help-row\"><span>Previous / next page</span><span class=\"help-keys\"><kbd class=\"kbd\">←</kbd><kbd class=\"kbd\">→</kbd> or <kbd class=\"kbd\">h</kbd><kbd class=\"kbd\">l</kbd></span></div><div class=\"help-row\"><span>Return to previous location</span><span class=\"help-keys\"><kbd class=\"kbd\">Alt</kbd>+<kbd class=\"kbd\">←</kbd></span></div><div class=\"help-row\"><span>Page edge zones <span class=\"help-note\">(desktop)</span></span><span class=\"help-keys\">click page edges</span></div><div class=\"help-row\" x-show=\"!isFixedLayout || isPDF\"><span>Search in book</span><span class=\"help-keys\"><kbd class=\"kbd\">/</kbd></span></div><div class=\"help-row\"><span>Contents / thumbnails</span><span class=\"help-keys\"><kbd class=\"kbd\">t</kbd></span></div><div class=\"help-row\"><span>Annotations drawer</span><span class=\"help-keys\"><kbd class=\"kbd\">a</kbd></span></div><div class=\"help-row\"><span>Settings</span><span class=\"help-keys\"><kbd class=\"kbd\">s</kbd></span></div><div class=\"help-row\"><span>Fullscreen</span><span class=\"help-keys\"><kbd class=\"kbd\">f</kbd></span></div><div class=\"help-row\"><span>Add bookmark</span><span class=\"help-keys\"><kbd class=\"kbd\">b</kbd></span></div><div class=\"help-row\"><span>Close menus / toggle chrome</span><span class=\"help-keys\"><kbd class=\"kbd\">Esc</kbd></span></div><h4 class=\"reader-help-section\" x-show=\"isFixedLayout\">Zoom & pan <span class=\"help-note\">(comics, PDFs)</span></h4><div class=\"help-row\" x-show=\"isFixedLayout\"><span>Zoom in / out / reset</span><span class=\"help-keys\"><kbd class=\"kbd\">+</kbd><kbd class=\"kbd\">−</kbd><kbd class=\"kbd\">0</kbd> or wheel</span></div><div class=\"help-row\" x-show=\"isFixedLayout\"><span>Move the page</span><span class=\"help-keys\">drag with the mouse</span></div><div class=\"help-row\" x-show=\"isFixedLayout\"><span>Recenter <span class=\"help-note\">(keeps zoom)</span></span><span class=\"help-keys\">⊙ button</span></div><div class=\"help-row\" x-show=\"isPDF\"><span>Select text in a PDF</span><span class=\"help-keys\">Smart mode: drag over text · <kbd class=\"kbd\">Shift</kbd>+drag anywhere · Text mode</span></div><h4 class=\"reader-help-section\">Highlights</h4><div class=\"help-row\"><span>Create</span><span class=\"help-keys\">select text → pick a color</span></div><div class=\"help-row\"><span>Edit / delete</span><span class=\"help-keys\">click the highlight</span></div><h4 class=\"reader-help-section\">Touch</h4><div class=\"help-row\"><span>Page turn</span><span class=\"help-keys\">swipe, or tap outer edges</span></div><div class=\"help-row\"><span>Toggle chrome</span><span class=\"help-keys\">tap center</span></div><div class=\"help-row\" x-show=\"isFixedLayout\"><span>Zoom / move</span><span class=\"help-keys\">pinch · two-finger drag · double-tap</span></div></div></div></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div><!-- Shortcuts & gestures help modal --><div x-show=\"helpOpen\" x-transition.opacity.duration.200ms class=\"reader-help-backdrop\" @click=\"helpOpen = false\" aria-hidden=\"true\"></div><div x-show=\"helpOpen\" x-transition:enter=\"transition ease-out duration-150\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-100\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"reader-help\" role=\"dialog\" aria-label=\"Shortcuts and gestures\"><div class=\"reader-help-header\"><h3 class=\"font-semibold text-base\">Shortcuts & Gestures</h3><button @click=\"helpOpen = false\" class=\"p-1.5 rounded-lg hover:bg-gray-700\" title=\"Close (Esc)\" aria-label=\"Close\">✕</button></div><div class=\"reader-help-body\"><h4 class=\"reader-help-section\">Navigation</h4><div class=\"help-row\"><span>Previous / next page</span><span class=\"help-keys\"><kbd class=\"kbd\">←</kbd><kbd class=\"kbd\">→</kbd> or <kbd class=\"kbd\">h</kbd><kbd class=\"kbd\">l</kbd></span></div><div class=\"help-row\"><span>Return to previous location</span><span class=\"help-keys\"><kbd class=\"kbd\">Alt</kbd>+<kbd class=\"kbd\">←</kbd></span></div><div class=\"help-row\"><span>Page edge zones <span class=\"help-note\">(desktop)</span></span><span class=\"help-keys\">click page edges</span></div><div class=\"help-row\" x-show=\"!isFixedLayout || isPDF\"><span>Search in book</span><span class=\"help-keys\"><kbd class=\"kbd\">/</kbd></span></div><div class=\"help-row\"><span>Contents / thumbnails</span><span class=\"help-keys\"><kbd class=\"kbd\">t</kbd></span></div><div class=\"help-row\"><span>Annotations drawer</span><span class=\"help-keys\"><kbd class=\"kbd\">a</kbd></span></div><div class=\"help-row\"><span>Settings</span><span class=\"help-keys\"><kbd class=\"kbd\">s</kbd></span></div><div class=\"help-row\"><span>Fullscreen</span><span class=\"help-keys\"><kbd class=\"kbd\">f</kbd></span></div><div class=\"help-row\"><span>Add bookmark</span><span class=\"help-keys\"><kbd class=\"kbd\">b</kbd></span></div><div class=\"help-row\"><span>Close menus / toggle chrome</span><span class=\"help-keys\"><kbd class=\"kbd\">Esc</kbd></span></div><h4 class=\"reader-help-section\" x-show=\"isFixedLayout\">Zoom & pan <span class=\"help-note\">(comics, PDFs)</span></h4><div class=\"help-row\" x-show=\"isFixedLayout\"><span>Zoom in / out / reset</span><span class=\"help-keys\"><kbd class=\"kbd\">+</kbd><kbd class=\"kbd\">−</kbd><kbd class=\"kbd\">0</kbd> or wheel</span></div><div class=\"help-row\" x-show=\"isFixedLayout\"><span>Move the page</span><span class=\"help-keys\">drag with the mouse</span></div><div class=\"help-row\" x-show=\"isFixedLayout\"><span>Recenter <span class=\"help-note\">(keeps zoom)</span></span><span class=\"help-keys\">⊙ button</span></div><div class=\"help-row\" x-show=\"isPDF\"><span>Select text in a PDF</span><span class=\"help-keys\">Smart mode: drag over text · <kbd class=\"kbd\">Shift</kbd>+drag anywhere · Text mode</span></div><h4 class=\"reader-help-section\">Highlights</h4><div class=\"help-row\"><span>Create</span><span class=\"help-keys\">select text → pick a color</span></div><div class=\"help-row\"><span>Edit / delete</span><span class=\"help-keys\">click the highlight</span></div><h4 class=\"reader-help-section\">Touch</h4><div class=\"help-row\"><span>Page turn</span><span class=\"help-keys\">swipe, or tap outer edges</span></div><div class=\"help-row\"><span>Toggle chrome</span><span class=\"help-keys\">tap center</span></div><div class=\"help-row\" x-show=\"isFixedLayout\"><span>Zoom / move</span><span class=\"help-keys\">pinch · two-finger drag · double-tap</span></div></div></div></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -164,38 +177,38 @@ func ReaderChrome(metadata ReaderMetadata) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var6 == nil {
|
if templ_7745c5c3_Var7 == nil {
|
||||||
templ_7745c5c3_Var6 = templ.NopComponent
|
templ_7745c5c3_Var7 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div id=\"reader-chrome\" class=\"transition-opacity duration-300\" :class=\"chromeVisible ? 'opacity-100' : 'chrome-hidden opacity-0 pointer-events-none'\"><!-- Top bar --><div id=\"reader-topbar\" class=\"fixed top-0 left-0 right-0 border-b z-40 pt-[env(safe-area-inset-top)] reader-glass\"><div class=\"flex items-center justify-between px-3 py-2 sm:px-4\"><a id=\"reader-back\" href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div id=\"reader-chrome\" class=\"transition-opacity duration-300\" :class=\"chromeVisible ? 'opacity-100' : 'chrome-hidden opacity-0 pointer-events-none'\"><!-- Top bar --><div id=\"reader-topbar\" class=\"fixed top-0 left-0 right-0 border-b z-40 pt-[env(safe-area-inset-top)] reader-glass\"><div class=\"flex items-center justify-between px-3 py-2 sm:px-4\"><a id=\"reader-back\" href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 templ.SafeURL
|
var templ_7745c5c3_Var8 templ.SafeURL
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + metadata.MediaItemID)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + metadata.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 303, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 303, Col: 63}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"text-base sm:text-lg hover:underline\">← Back</a><h1 class=\"text-base sm:text-lg font-semibold hidden sm:block sm:truncate\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var8 string
|
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 306, Col: 95}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><div class=\"flex items-center gap-1\"><button @click=\"addBookmark()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark this position (b)\">🏷️</button> <button x-show=\"(!isFixedLayout || isPDF) && !treatAsComic\" @click=\"toggleSearch()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Search (/)\">🔍</button> <button @click=\"toggleBookmarks()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Annotations (a)\">📝</button> <button @click=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings (s)\">Aa</button> <button @click=\"toggleHelp()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold\" title=\"Shortcuts & gestures (?)\" aria-label=\"Shortcuts and gestures\">?</button> <button @click=\"toggleFullscreen()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Fullscreen (f)\" aria-label=\"Toggle fullscreen\"><svg x-show=\"!isFullscreen\" class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"></path></svg> <svg x-show=\"isFullscreen\" x-cloak class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3\"></path></svg></button></div></div></div><!-- Bottom bar --><div id=\"reader-bottombar\" class=\"fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass\"><!-- Reflowable row --><div x-show=\"!isFixedLayout\" class=\"flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5 grow min-w-[190px]\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button> <input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" @pointerup=\"$el.blur()\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button></div><div class=\"flex items-center gap-1\"><div class=\"w-px h-6 reader-sep\"></div><div id=\"progress-display\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden\"><span class=\"hidden sm:inline\" x-text=\"progressLabel\"></span><span x-text=\"progressMain\">—</span></div><div class=\"w-px h-6 reader-sep\"></div><button @click=\"toggleTOC()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Table of Contents (t)\">📖</button></div></div><!-- Fixed-layout row (comics, PDFs, manga) --><div x-show=\"isFixedLayout\"><!-- Full toolbar: desktop/tablet (>= 768px, md). Wraps gracefully in between. --><div class=\"hidden md:flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left (←)\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button></div><input id=\"progress-slider-fx\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" @input=\"goToFraction($event.target.value)\" @pointerup=\"$el.blur()\" class=\"grow min-w-[3rem]\"> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right (→)\" aria-label=\"Go right\"><svg class=\"reader-iLine truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"text-base sm:text-lg hover:underline\">← Back</a><h1 class=\"text-base sm:text-lg font-semibold hidden sm:block sm:truncate\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 306, Col: 95}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</h1><div class=\"flex items-center gap-1\"><button @click=\"addBookmark()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark this position (b)\">🏷️</button> <button x-show=\"(!isFixedLayout || isPDF) && !treatAsComic\" @click=\"toggleSearch()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Search (/)\">🔍</button> <button @click=\"toggleBookmarks()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Annotations (a)\">📝</button> <button @click=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings (s)\">Aa</button> <button @click=\"toggleHelp()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold\" title=\"Shortcuts & gestures (?)\" aria-label=\"Shortcuts and gestures\">?</button> <button @click=\"toggleFullscreen()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Fullscreen (f)\" aria-label=\"Toggle fullscreen\"><svg x-show=\"!isFullscreen\" class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"></path></svg> <svg x-show=\"isFullscreen\" x-cloak class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3\"></path></svg></button></div></div></div><!-- Bottom bar --><div id=\"reader-bottombar\" class=\"fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass\"><!-- Reflowable row --><div x-show=\"!isFixedLayout\" class=\"flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5 grow min-w-[190px]\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button> <input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" @pointerup=\"$el.blur()\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button></div><div class=\"flex items-center gap-1\"><div class=\"w-px h-6 reader-sep\"></div><div id=\"progress-display\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden\"><span class=\"hidden sm:inline\" x-text=\"progressLabel\"></span><span x-text=\"progressMain\">—</span></div><div class=\"w-px h-6 reader-sep\"></div><button @click=\"toggleTOC()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Table of Contents (t)\">📖</button></div></div><!-- Fixed-layout row (comics, PDFs, manga) --><div x-show=\"isFixedLayout\"><!-- Full toolbar: desktop/tablet (>= 768px, md). Wraps gracefully in between. --><div class=\"hidden md:flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left (←)\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button></div><input id=\"progress-slider-fx\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" @input=\"goToFraction($event.target.value)\" @pointerup=\"$el.blur()\" class=\"grow min-w-[3rem]\"> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right (→)\" aria-label=\"Go right\"><svg class=\"reader-iLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -219,25 +232,25 @@ func drawerHeader(title string) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var9 == nil {
|
if templ_7745c5c3_Var10 == nil {
|
||||||
templ_7745c5c3_Var9 = templ.NopComponent
|
templ_7745c5c3_Var10 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"reader-drawer-header\"><h3 class=\"font-semibold\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"reader-drawer-header\"><h3 class=\"font-semibold\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 578, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 578, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h3><button @click=\"closeDrawers()\" class=\"p-1.5 rounded-lg hover:bg-gray-700\" title=\"Close (Esc)\" aria-label=\"Close\">✕</button></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</h3><button @click=\"closeDrawers()\" class=\"p-1.5 rounded-lg hover:bg-gray-700\" title=\"Close (Esc)\" aria-label=\"Close\">✕</button></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -261,16 +274,16 @@ func ReaderTOCDrawer() templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var11 == nil {
|
if templ_7745c5c3_Var12 == nil {
|
||||||
templ_7745c5c3_Var11 = templ.NopComponent
|
templ_7745c5c3_Var12 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = drawerHeader("Contents").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = drawerHeader("Contents").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<!-- Fixed-layout books get a Pages (thumbnails) tab; outline TOC may be\n\t absent in manga-scan PDFs and CBZs. --><div class=\"reader-tabs\" x-show=\"isFixedLayout\"><button :class=\"tocTab === 'contents' ? 'active' : ''\" @click=\"tocTab = 'contents'\">Contents <span class=\"reader-tab-count\" x-text=\"tocItems.length\">0</span></button> <button :class=\"tocTab === 'pages' ? 'active' : ''\" @click=\"openPageThumbs()\">Pages <span class=\"reader-tab-count\" x-text=\"pageThumbList.length || ''\">0</span></button></div><div class=\"reader-drawer-body\"><nav id=\"toc-list\" class=\"space-y-1\" x-show=\"tocTab === 'contents' || !isFixedLayout\"><!-- Key must include the index: PDF outline entries can share the\n\t\t\t same destination href, and Alpine's x-for renders nothing on\n\t\t\t duplicate keys. --><template x-for=\"(item, idx) in tocItems\" :key=\"item.href + '::' + idx\"><a href=\"#\" @click.prevent=\"goToTOCItem(item)\" class=\"block py-1 px-2 rounded hover:bg-gray-700 text-sm\" :style=\"'padding-left: ' + ((item.depth || 0) * 1 + 0.5) + 'rem'\" x-text=\"item.label\"></a></template><template x-if=\"tocItems.length === 0\"><p class=\"text-sm py-2\" style=\"color: var(--text-secondary)\">No table of contents</p></template></nav><div id=\"page-thumb-grid\" class=\"reader-thumb-grid\" x-show=\"isFixedLayout && tocTab === 'pages'\"><template x-for=\"p in pageThumbList\" :key=\"p.index\"><button class=\"reader-thumb\" :class=\"p.index === currentPageIndex ? 'active' : ''\" @click=\"goToPage(p.index)\" :title=\"'Page ' + (p.index + 1)\"><div class=\"reader-thumb-img\" :data-page=\"p.index\"></div><span class=\"reader-thumb-num\" x-text=\"p.index + 1\">1</span></button></template><template x-if=\"pageThumbList.length === 0\"><p class=\"text-sm py-2\" style=\"color: var(--text-secondary)\">No pages</p></template></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<!-- Fixed-layout books get a Pages (thumbnails) tab; outline TOC may be\n\t absent in manga-scan PDFs and CBZs. --><div class=\"reader-tabs\" x-show=\"isFixedLayout\"><button :class=\"tocTab === 'contents' ? 'active' : ''\" @click=\"tocTab = 'contents'\">Contents <span class=\"reader-tab-count\" x-text=\"tocItems.length\">0</span></button> <button :class=\"tocTab === 'pages' ? 'active' : ''\" @click=\"openPageThumbs()\">Pages <span class=\"reader-tab-count\" x-text=\"pageThumbList.length || ''\">0</span></button></div><div class=\"reader-drawer-body\"><nav id=\"toc-list\" class=\"space-y-1\" x-show=\"tocTab === 'contents' || !isFixedLayout\"><!-- Key must include the index: PDF outline entries can share the\n\t\t\t same destination href, and Alpine's x-for renders nothing on\n\t\t\t duplicate keys. --><template x-for=\"(item, idx) in tocItems\" :key=\"item.href + '::' + idx\"><a href=\"#\" @click.prevent=\"goToTOCItem(item)\" class=\"block py-1 px-2 rounded hover:bg-gray-700 text-sm\" :style=\"'padding-left: ' + ((item.depth || 0) * 1 + 0.5) + 'rem'\" x-text=\"item.label\"></a></template><template x-if=\"tocItems.length === 0\"><p class=\"text-sm py-2\" style=\"color: var(--text-secondary)\">No table of contents</p></template></nav><div id=\"page-thumb-grid\" class=\"reader-thumb-grid\" x-show=\"isFixedLayout && tocTab === 'pages'\"><template x-for=\"p in pageThumbList\" :key=\"p.index\"><button class=\"reader-thumb\" :class=\"p.index === currentPageIndex ? 'active' : ''\" @click=\"goToPage(p.index)\" :title=\"'Page ' + (p.index + 1)\"><div class=\"reader-thumb-img\" :data-page=\"p.index\"></div><span class=\"reader-thumb-num\" x-text=\"p.index + 1\">1</span></button></template><template x-if=\"pageThumbList.length === 0\"><p class=\"text-sm py-2\" style=\"color: var(--text-secondary)\">No pages</p></template></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -294,16 +307,16 @@ func ReaderAnnotationsDrawer() templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var13 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var12 == nil {
|
if templ_7745c5c3_Var13 == nil {
|
||||||
templ_7745c5c3_Var12 = templ.NopComponent
|
templ_7745c5c3_Var13 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = drawerHeader("Annotations").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = drawerHeader("Annotations").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"reader-tabs\"><button :class=\"annotationsTab === 'highlights' ? 'active' : ''\" @click=\"annotationsTab = 'highlights'\">Highlights <span class=\"reader-tab-count\" x-text=\"highlightItems.length\">0</span></button> <button :class=\"annotationsTab === 'notes' ? 'active' : ''\" @click=\"annotationsTab = 'notes'\">Notes <span class=\"reader-tab-count\" x-text=\"noteItems.length\">0</span></button> <button :class=\"annotationsTab === 'bookmarks' ? 'active' : ''\" @click=\"annotationsTab = 'bookmarks'\">Bookmarks <span class=\"reader-tab-count\" x-text=\"bookmarkItems.length\">0</span></button></div><div class=\"reader-drawer-body\"><!-- Highlights --><div x-show=\"annotationsTab === 'highlights'\" class=\"space-y-2\"><template x-for=\"hl in highlightItems\" :key=\"hl.id\"><div class=\"reader-hl-row group\"><a href=\"#\" @click.prevent=\"goToHighlight(hl)\" class=\"flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700\"><span class=\"block text-sm truncate\" :style=\"'border-left: 3px solid ' + hl.color + '; padding-left: 0.5rem;'\" x-text=\"hl.text\"></span> <span class=\"note-rich block text-xs mt-0.5 truncate pl-2\" style=\"color: var(--text-secondary)\" x-show=\"hl.note\" x-html=\"'📝 ' + hl.noteHtml\"></span></a> <button @click=\"deleteHighlightById(hl.id)\" class=\"p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity\" title=\"Delete highlight\" aria-label=\"Delete highlight\">✕</button></div></template><template x-if=\"highlightItems.length === 0\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Select text in the book to highlight it</p></template></div><!-- Notes --><div x-show=\"annotationsTab === 'notes'\"><textarea x-model=\"newNoteText\" rows=\"2\" class=\"reader-note-input\" placeholder=\"Add a note at the current position…\" @paste=\"richNotePaste\"></textarea><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">Markdown supported — rich-text paste converts to markdown</p><div class=\"text-xs mt-0.5 text-right\" x-show=\"newNoteText.length > 0\" :style=\"noteOverLimit(newNoteText) ? 'color: #f87171;' : 'color: var(--text-secondary);'\" x-text=\"noteCountLabel(newNoteText)\"></div><button @click=\"submitNote()\" :disabled=\"noteOverLimit(newNoteText)\" class=\"w-full py-1.5 mt-1 mb-3 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\">+ Add Note</button><div class=\"space-y-2\"><template x-for=\"note in noteItems\" :key=\"note.id\"><div class=\"reader-hl-row group\"><template x-if=\"!note.editOpen\"><div class=\"flex\"><a href=\"#\" @click.prevent=\"closeDrawers()\" class=\"flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700\"><span class=\"note-rich block text-sm truncate\" x-html=\"note.contentHtml\"></span> <span class=\"block text-xs mt-0.5 truncate\" style=\"color: var(--text-secondary)\" x-text=\"note.positionLabel\"></span></a> <button @click=\"startNoteEdit(note)\" class=\"p-2 rounded hover:bg-gray-700 opacity-0 group-hover:opacity-100 transition-opacity\" title=\"Edit note\" aria-label=\"Edit note\">✎</button> <button @click=\"deleteNoteById(note.id)\" class=\"p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity\" title=\"Delete note\" aria-label=\"Delete note\">✕</button></div></template><!-- Inline edit: mirrors the bookmark-rename pattern --><div class=\"w-full py-2\" x-show=\"note.editOpen\" @click.stop><textarea x-model=\"note.editText\" rows=\"3\" class=\"reader-note-input\"></textarea><div class=\"flex gap-2 mt-1\"><button @click=\"saveNoteEdit(note)\" class=\"flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\" :disabled=\"!noteEditHasText(note)\">Save</button> <button @click=\"cancelNoteEdit(note)\" class=\"flex-1 py-1 text-xs border rounded hover:opacity-80\" style=\"border-color: var(--border);\">Cancel</button></div></div></div></template><template x-if=\"noteItems.length === 0\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No notes yet</p></template></div></div><!-- Bookmarks --><div x-show=\"annotationsTab === 'bookmarks'\" class=\"space-y-2\"><template x-for=\"bookmark in bookmarkItems\" :key=\"bookmark.id\"><div class=\"flex items-center gap-1 group\"><a href=\"#\" @click.prevent=\"goToBookmark(bookmark)\" class=\"flex-1 min-w-0 block py-2 hover:bg-gray-700 rounded px-2\" x-show=\"!bookmark.renameOpen\"><span class=\"font-medium block truncate\" x-text=\"bookmark.title\"></span> <span class=\"text-xs block truncate\" style=\"color: var(--text-secondary)\" x-text=\"bookmark.positionLabel\"></span></a><div class=\"flex-1 min-w-0 py-2\" x-show=\"bookmark.renameOpen\" @click.stop><input type=\"text\" class=\"reader-note-input\" x-model=\"bookmark.renameText\" @keydown.enter=\"renameBookmark(bookmark)\" @keydown.escape=\"bookmark.renameOpen = falsLine truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"reader-tabs\"><button :class=\"annotationsTab === 'highlights' ? 'active' : ''\" @click=\"annotationsTab = 'highlights'\">Highlights <span class=\"reader-tab-count\" x-text=\"highlightItems.length\">0</span></button> <button :class=\"annotationsTab === 'notes' ? 'active' : ''\" @click=\"annotationsTab = 'notes'\">Notes <span class=\"reader-tab-count\" x-text=\"noteItems.length\">0</span></button> <button :class=\"annotationsTab === 'bookmarks' ? 'active' : ''\" @click=\"annotationsTab = 'bookmarks'\">Bookmarks <span class=\"reader-tab-count\" x-text=\"bookmarkItems.length\">0</span></button></div><div class=\"reader-drawer-body\"><!-- Highlights --><div x-show=\"annotationsTab === 'highlights'\" class=\"space-y-2\"><template x-for=\"hl in highlightItems\" :key=\"hl.id\"><div class=\"reader-hl-row group\"><a href=\"#\" @click.prevent=\"goToHighlight(hl)\" class=\"flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700\"><span class=\"block text-sm truncate\" :style=\"'border-left: 3px solid ' + hl.color + '; padding-left: 0.5rem;'\" x-text=\"hl.text\"></span> <span class=\"note-rich block text-xs mt-0.5 truncate pl-2\" style=\"color: var(--text-secondary)\" x-show=\"hl.note\" x-html=\"'📝 ' + hl.noteHtml\"></span></a> <button @click=\"deleteHighlightById(hl.id)\" class=\"p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity\" title=\"Delete highlight\" aria-label=\"Delete highlight\">✕</button></div></template><template x-if=\"highlightItems.length === 0\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Select text in the book to highlight it</p></template></div><!-- Notes --><div x-show=\"annotationsTab === 'notes'\"><textarea x-model=\"newNoteText\" rows=\"2\" class=\"reader-note-input\" placeholder=\"Add a note at the current position…\" @paste=\"richNotePaste\"></textarea><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">Markdown supported — rich-text paste converts to markdown</p><div class=\"text-xs mt-0.5 text-right\" x-show=\"newNoteText.length > 0\" :style=\"noteOverLimit(newNoteText) ? 'color: #f87171;' : 'color: var(--text-secondary);'\" x-text=\"noteCountLabel(newNoteText)\"></div><button @click=\"submitNote()\" :disabled=\"noteOverLimit(newNoteText)\" class=\"w-full py-1.5 mt-1 mb-3 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\">+ Add Note</button><div class=\"space-y-2\"><template x-for=\"note in noteItems\" :key=\"note.id\"><div class=\"reader-hl-row group\"><template x-if=\"!note.editOpen\"><div class=\"flex\"><a href=\"#\" @click.prevent=\"closeDrawers()\" class=\"flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700\"><span class=\"note-rich block text-sm truncate\" x-html=\"note.contentHtml\"></span> <span class=\"block text-xs mt-0.5 truncate\" style=\"color: var(--text-secondary)\" x-text=\"note.positionLabel\"></span></a> <button @click=\"startNoteEdit(note)\" class=\"p-2 rounded hover:bg-gray-700 opacity-0 group-hover:opacity-100 transition-opacity\" title=\"Edit note\" aria-label=\"Edit note\">✎</button> <button @click=\"deleteNoteById(note.id)\" class=\"p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity\" title=\"Delete note\" aria-label=\"Delete note\">✕</button></div></template><!-- Inline edit: mirrors the bookmark-rename pattern --><div class=\"w-full py-2\" x-show=\"note.editOpen\" @click.stop><textarea x-model=\"note.editText\" rows=\"3\" class=\"reader-note-input\"></textarea><div class=\"flex gap-2 mt-1\"><button @click=\"saveNoteEdit(note)\" class=\"flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\" :disabled=\"!noteEditHasText(note)\">Save</button> <button @click=\"cancelNoteEdit(note)\" class=\"flex-1 py-1 text-xs border rounded hover:opacity-80\" style=\"border-color: var(--border);\">Cancel</button></div></div></div></template><template x-if=\"noteItems.length === 0\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No notes yet</p></template></div></div><!-- Bookmarks --><div x-show=\"annotationsTab === 'bookmarks'\" class=\"space-y-2\"><template x-for=\"bookmark in bookmarkItems\" :key=\"bookmark.id\"><div class=\"flex items-center gap-1 group\"><a href=\"#\" @click.prevent=\"goToBookmark(bookmark)\" class=\"flex-1 min-w-0 block py-2 hover:bg-gray-700 rounded px-2\" x-show=\"!bookmark.renameOpen\"><span class=\"font-medium block truncate\" x-text=\"bookmark.title\"></span> <span class=\"text-xs block truncate\" style=\"color: var(--text-secondary)\" x-text=\"bookmark.positionLabel\"></span></a><div class=\"flex-1 min-w-0 py-2\" x-show=\"bookmark.renameOpen\" @click.stop><input type=\"text\" class=\"reader-note-input\" x-model=\"bookmark.renameText\" @keydown.enter=\"renameBookmark(bookmark)\" @keydown.escape=\"bookmark.renameOpen = falsLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -327,16 +340,16 @@ func ReaderSearchDrawer() templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var13 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var14 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var13 == nil {
|
if templ_7745c5c3_Var14 == nil {
|
||||||
templ_7745c5c3_Var13 = templ.NopComponent
|
templ_7745c5c3_Var14 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = drawerHeader("Search").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = drawerHeader("Search").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"reader-search-bar\"><input type=\"search\" x-ref=\"searchInput\" x-model=\"searchQuery\" @keydown.enter.prevent=\"runSearch()\" placeholder=\"Search in book…\" class=\"reader-note-input grow\" aria-label=\"Search query\"> <button x-show=\"searchQuery || searchGroups.length\" @click=\"clearSearch()\" class=\"p-2 rounded-lg hover:bg-gray-700 shrink-0\" title=\"Clear results\" aria-label=\"Clear results\">✕</button></div><div class=\"reader-search-status\" x-show=\"searchQuery || searching || searchGroups.length || searchError\"><span x-show=\"searching\" x-text=\"searchProgressLabel()\">Searching…</span> <span x-show=\"!searching && searchGroups.length\" x-text=\"searchMatchCount + ' match' + (searchMatchCount === 1 ? '' : 'es')\">0 matches</span> <span x-show=\"!searching && searchQuery && !searchGroups.length && !searchError\">No matches</span> <span x-show=\"searchError\" x-text=\"searchError\" style=\"color: #f87171;\">Search failed</span></div><div class=\"reader-drawer-body\"><template x-for=\"(group, gi) in searchGroups\" :key=\"gi\"><div class=\"mb-3\"><div class=\"text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\" x-text=\"group.label\"></div><div class=\"space-y-1\"><template x-for=\"(item, si) in group.items\" :key=\"gi + '-' + si\"><a href=\"#\" @click.prevent=\"goToSearchResult(item)\" class=\"search-result\"><span x-text=\"item.pre\"></span><mark x-text=\"item.match\"></mark><span x-text=\"item.post\"></span></a></template></div></div></template></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"reader-search-bar\"><input type=\"search\" x-ref=\"searchInput\" x-model=\"searchQuery\" @keydown.enter.prevent=\"runSearch()\" placeholder=\"Search in book…\" class=\"reader-note-input grow\" aria-label=\"Search query\"> <button x-show=\"searchQuery || searchGroups.length\" @click=\"clearSearch()\" class=\"p-2 rounded-lg hover:bg-gray-700 shrink-0\" title=\"Clear results\" aria-label=\"Clear results\">✕</button></div><div class=\"reader-search-status\" x-show=\"searchQuery || searching || searchGroups.length || searchError\"><span x-show=\"searching\" x-text=\"searchProgressLabel()\">Searching…</span> <span x-show=\"!searching && searchGroups.length\" x-text=\"searchMatchCount + ' match' + (searchMatchCount === 1 ? '' : 'es')\">0 matches</span> <span x-show=\"!searching && searchQuery && !searchGroups.length && !searchError\">No matches</span> <span x-show=\"searchError\" x-text=\"searchError\" style=\"color: #f87171;\">Search failed</span></div><div class=\"reader-drawer-body\"><template x-for=\"(group, gi) in searchGroups\" :key=\"gi\"><div class=\"mb-3\"><div class=\"text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\" x-text=\"group.label\"></div><div class=\"space-y-1\"><template x-for=\"(item, si) in group.items\" :key=\"gi + '-' + si\"><a href=\"#\" @click.prevent=\"goToSearchResult(item)\" class=\"search-result\"><span x-text=\"item.pre\"></span><mark x-text=\"item.match\"></mark><span x-text=\"item.post\"></span></a></template></div></div></template></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -360,16 +373,16 @@ func ReaderSettingsDrawer() templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var14 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var15 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var14 == nil {
|
if templ_7745c5c3_Var15 == nil {
|
||||||
templ_7745c5c3_Var14 = templ.NopComponent
|
templ_7745c5c3_Var15 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = drawerHeader("Settings").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = drawerHeader("Settings").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"reader-drawer-body\"><!-- Behavior --><div class=\"mb-6\"><h3 class=\"font-semibold mb-2\">Behavior</h3><label class=\"block mb-2\">Chrome <select name=\"chrome_behavior\" x-model=\"chromeBehavior\" @change=\"applyChromeBehavior()\" class=\"w-full mt-1 px-3 py-2 rounded border\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><option value=\"auto-hide\">Auto Hide</option> <option value=\"always-visible\">Always Visible</option></select></label> <label class=\"block mb-2\">Progress Mode <select name=\"progress_mode\" x-model=\"progressMode\" @change=\"applyProgressMode()\" class=\"w-full mt-1 px-3 py-2 rounded border\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><option value=\"pages\">Pages</option> <option value=\"chapter\">Chapter</option> <option value=\"percentage\">Percentage</option> <option value=\"time-left\">Time Left</option></select></label> <label class=\"flex items-center mb-2\"><input type=\"checkbox\" name=\"tap_zones_enabled\" x-model=\"tapZonesEnabled\" @change=\"applyTapZoneSettings()\" class=\"mr-2\"> Tap Zones <span class=\"text-xs ml-1\" style=\"color: var(--text-secondary)\">(touch)</span></label> <label class=\"block mb-2\" x-show=\"tapZonesEnabled\">Tap Zone Size <input type=\"range\" name=\"tap_zone_size\" x-model.number=\"tapZoneSize\" @change=\"applyTapZoneSettings()\" min=\"10\" max=\"50\" class=\"w-full\"> <span class=\"text-xs\" x-text=\"tapZoneSize + '%'\" style=\"color: var(--text-secondary)\">30%</span></label></div><!-- Reading Theme (reflowable only) --><div class=\"mb-6\" x-show=\"!isFixedLayout\"><div class=\"flex items-center justify-between mb-2\"><h3 class=\"font-semibold\">Reading Theme</h3><button @click=\"toggleReadingMode()\" class=\"relative w-14 h-7 rounded-full transition-colors duration-200 flex items-center px-1\" :class=\"readingMode === 'dark' ? 'bg-blue-500' : 'bg-gray-400'\" type=\"button\" :aria-label=\"readingMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'\"><span class=\"absolute top-0.5 left-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform duration-200 flex items-center justify-center text-xs\" :class=\"readingMode === 'dark' ? 'translate-x-7' : 'translate-x-0'\" x-text=\"readingMode === 'dark' ? '🌙' : '☀️'\">☀️</span></button></div><div class=\"grid grid-cols-6 gap-2\"><template x-for=\"sw in themeSwatches\" :key=\"sw.value\"><button type=\"button\" @click=\"pickReadingTheme(sw.value)\" class=\"theme-swatch\" :class=\"readingTheme === sw.value ? 'ring-2 ring-blue-500' : ''\" :style=\"'background-color:' + sw.bg + '; color:' + sw.fg\" :title=\"sw.label\" :aria-label=\"'Theme ' + sw.label\" :aria-pressed=\"readingTheme === sw.value\"><span class=\"text-xs font-semibold\">Aa</span></button></template></div></div><!-- Typography (reflowable only) --><div class=\"mb-6\" x-show=\"!isFixedLayout\"><h3 class=\"font-semibold mb-2\">Typography</h3><label class=\"block mb-2\">Reading Font <select name=\"reading_font\" x-model=\"readingFont\" @change=\"applyFont()\" class=\"w-full mt-1 px-3 py-2 rounded border\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><option value=\"literata\">Literata (Default)</option> <option value=\"crimson\">Crimson Text</option> <option value=\"source-serif\">Source Serif 4</option> <option value=\"eb-garamond\">EB Garamond</option> <option value=\"libertinus\">Libertinus Serif</option> <option value=\"noto-serif\">Noto Serif</option> <option value=\"charis-sil\">Charis SIL</option> <option value=\"ibm-plex\">IBM Plex Serif</option></select></label> <label class=\"block mb-2\">Font Size <input type=\"range\" name=\"font_size\" x-model.number=\"fontSize\" @change=\"applyFont()\" min=\"10\" max=\"40\" class=\"w-full\"> <span class=\"text-xs\" x-text=\"fontSize + 'px'\" style=\"color: var(--text-secondary)\">18px</span></label> <label class=\"block mb-2\">Line Height <input type=\"range\" name=\"line_height\" x-model.number=\"lineHeight\" @change=\"applyFont()\" min=\"0.8\" max=\"3.0\" step=\"0.1\" class=\"w-full\"> <span class=\"text-xs\" x-text=\"lineHeight\" style=\"color: var(--text-secondary)\">1.6</span></label></div><!-- Layout & display (fixed-layout only) --><div class=\"mb-6\" x-show=\"isFixedLayout\"><h3 class=\"font-semibold mb-2\">Layout & Display</h3><label class=\"block mb-3\" x-show=\"isComic\">Reading Flow<div class=\"reader-seg w-full mt-1\" role=\"group\" aria-label=\"Reading flow\"><button type=\"button\" class=\"flex-1\" :class=\"comicFlow !== 'webtoon' ? 'active' : ''\" @click=\"setComicFlow('paged')\" title=\"One page or spread at a time\">Paged</button> <button type=\"button\" class=\"flex-1\" :class=\"comicFlow === 'webtoon' ? 'active' : ''\" @click=\"setComicFlow('webtoon')\" title=\"Continuous vertical scroll (long-strip)\">Webtoon</button></div></label> <label class=\"block mb-2\">Line truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"reader-drawer-body\"><!-- Behavior --><div class=\"mb-6\"><h3 class=\"font-semibold mb-2\">Behavior</h3><label class=\"block mb-2\">Chrome <select name=\"chrome_behavior\" x-model=\"chromeBehavior\" @change=\"applyChromeBehavior()\" class=\"w-full mt-1 px-3 py-2 rounded border\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><option value=\"auto-hide\">Auto Hide</option> <option value=\"always-visible\">Always Visible</option></select></label> <label class=\"block mb-2\">Progress Mode <select name=\"progress_mode\" x-model=\"progressMode\" @change=\"applyProgressMode()\" class=\"w-full mt-1 px-3 py-2 rounded border\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><option value=\"pages\">Pages</option> <option value=\"chapter\">Chapter</option> <option value=\"percentage\">Percentage</option> <option value=\"time-left\">Time Left</option></select></label> <label class=\"flex items-center mb-2\"><input type=\"checkbox\" name=\"tap_zones_enabled\" x-model=\"tapZonesEnabled\" @change=\"applyTapZoneSettings()\" class=\"mr-2\"> Tap Zones <span class=\"text-xs ml-1\" style=\"color: var(--text-secondary)\">(touch)</span></label> <label class=\"block mb-2\" x-show=\"tapZonesEnabled\">Tap Zone Size <input type=\"range\" name=\"tap_zone_size\" x-model.number=\"tapZoneSize\" @change=\"applyTapZoneSettings()\" min=\"10\" max=\"50\" class=\"w-full\"> <span class=\"text-xs\" x-text=\"tapZoneSize + '%'\" style=\"color: var(--text-secondary)\">30%</span></label></div><!-- Reading Theme (reflowable only) --><div class=\"mb-6\" x-show=\"!isFixedLayout\"><div class=\"flex items-center justify-between mb-2\"><h3 class=\"font-semibold\">Reading Theme</h3><button @click=\"toggleReadingMode()\" class=\"relative w-14 h-7 rounded-full transition-colors duration-200 flex items-center px-1\" :class=\"readingMode === 'dark' ? 'bg-blue-500' : 'bg-gray-400'\" type=\"button\" :aria-label=\"readingMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'\"><span class=\"absolute top-0.5 left-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform duration-200 flex items-center justify-center text-xs\" :class=\"readingMode === 'dark' ? 'translate-x-7' : 'translate-x-0'\" x-text=\"readingMode === 'dark' ? '🌙' : '☀️'\">☀️</span></button></div><div class=\"grid grid-cols-6 gap-2\"><template x-for=\"sw in themeSwatches\" :key=\"sw.value\"><button type=\"button\" @click=\"pickReadingTheme(sw.value)\" class=\"theme-swatch\" :class=\"readingTheme === sw.value ? 'ring-2 ring-blue-500' : ''\" :style=\"'background-color:' + sw.bg + '; color:' + sw.fg\" :title=\"sw.label\" :aria-label=\"'Theme ' + sw.label\" :aria-pressed=\"readingTheme === sw.value\"><span class=\"text-xs font-semibold\">Aa</span></button></template></div></div><!-- Typography (reflowable only) --><div class=\"mb-6\" x-show=\"!isFixedLayout\"><h3 class=\"font-semibold mb-2\">Typography</h3><label class=\"block mb-2\">Reading Font <select name=\"reading_font\" x-model=\"readingFont\" @change=\"applyFont()\" class=\"w-full mt-1 px-3 py-2 rounded border\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><option value=\"literata\">Literata (Default)</option> <option value=\"crimson\">Crimson Text</option> <option value=\"source-serif\">Source Serif 4</option> <option value=\"eb-garamond\">EB Garamond</option> <option value=\"libertinus\">Libertinus Serif</option> <option value=\"noto-serif\">Noto Serif</option> <option value=\"charis-sil\">Charis SIL</option> <option value=\"ibm-plex\">IBM Plex Serif</option></select></label> <label class=\"block mb-2\">Font Size <input type=\"range\" name=\"font_size\" x-model.number=\"fontSize\" @change=\"applyFont()\" min=\"10\" max=\"40\" class=\"w-full\"> <span class=\"text-xs\" x-text=\"fontSize + 'px'\" style=\"color: var(--text-secondary)\">18px</span></label> <label class=\"block mb-2\">Line Height <input type=\"range\" name=\"line_height\" x-model.number=\"lineHeight\" @change=\"applyFont()\" min=\"0.8\" max=\"3.0\" step=\"0.1\" class=\"w-full\"> <span class=\"text-xs\" x-text=\"lineHeight\" style=\"color: var(--text-secondary)\">1.6</span></label></div><!-- Layout & display (fixed-layout only) --><div class=\"mb-6\" x-show=\"isFixedLayout\"><h3 class=\"font-semibold mb-2\">Layout & Display</h3><label class=\"block mb-3\" x-show=\"isComic\">Reading Flow<div class=\"reader-seg w-full mt-1\" role=\"group\" aria-label=\"Reading flow\"><button type=\"button\" class=\"flex-1\" :class=\"comicFlow !== 'webtoon' ? 'active' : ''\" @click=\"setComicFlow('paged')\" title=\"One page or spread at a time\">Paged</button> <button type=\"button\" class=\"flex-1\" :class=\"comicFlow === 'webtoon' ? 'active' : ''\" @click=\"setComicFlow('webtoon')\" title=\"Continuous vertical scroll (long-strip)\">Webtoon</button></div></label> <label class=\"block mb-2\">Line truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompletedProgressThreshold is the viewer_percentage at which a card
|
||||||
|
// shows the completed check instead of a partial-progress bar — the
|
||||||
|
// SAME 0.999 cut the filter_query.go status arms and the series-page
|
||||||
|
// status filter use.
|
||||||
|
const CompletedProgressThreshold = 0.999
|
||||||
|
|
||||||
|
// progressFillCSS renders the partial-progress bar width (gotcha #8:
|
||||||
|
// quote-free style values only — templ double-escapes quotes into
|
||||||
|
// literal text the CSS parser never sees).
|
||||||
|
func progressFillCSS(pct float64) string {
|
||||||
|
return fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", pct*100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// highlightQueryHTML escapes text FIRST and only then wraps
|
||||||
|
// case-insensitive query matches in <mark> — the returned HTML is
|
||||||
|
// safe by construction (escaped payload, marker tags we wrote), which
|
||||||
|
// is what lets the search partial emit it via templ.Raw.
|
||||||
|
func highlightQueryHTML(text, query string) string {
|
||||||
|
if text == "" || query == "" {
|
||||||
|
return html.EscapeString(text)
|
||||||
|
}
|
||||||
|
pattern := regexp.MustCompile(regexp.QuoteMeta(html.EscapeString(query)))
|
||||||
|
markOpen := `<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">`
|
||||||
|
return pattern.ReplaceAllString(html.EscapeString(text), markOpen+"$0</mark>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchCoverURL falls back to the placeholder the way BookCard does —
|
||||||
|
// no inline onerror (CSP: hash-mode refuses native handler attributes).
|
||||||
|
func searchCoverURL(item handlers.BookInfo) string {
|
||||||
|
if item.CoverImagePath != "" {
|
||||||
|
return item.CoverImagePath
|
||||||
|
}
|
||||||
|
return "/static/placeholder-book.svg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchResults is the header-search dropdown partial: compact result
|
||||||
|
// rows swapped into #search-results by the htmx request the header
|
||||||
|
// input fires (layout=dropdown on /api/media-items/search). Content is
|
||||||
|
// server-rendered (rendering-ownership doctrine); web/src/search.ts
|
||||||
|
// only owns open/close/keyboard state.
|
||||||
|
templ SearchResults(items []handlers.BookInfo, query string) {
|
||||||
|
<div class="p-3 border-b" style="border-color: var(--border)">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
|
||||||
|
{ resultCountLabel(len(items), query) }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
if len(items) == 0 {
|
||||||
|
<div class="p-4 text-center">
|
||||||
|
<div class="text-4xl mb-2">🔍</div>
|
||||||
|
<p class="text-sm" style="color: var(--text-primary)">No results found</p>
|
||||||
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="max-h-96 overflow-y-auto">
|
||||||
|
for _, item := range items {
|
||||||
|
@SearchResultRow(item, query)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="p-2 border-t text-center" style="border-color: var(--border)">
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)">
|
||||||
|
Press <kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">↑↓</kbd> to navigate,
|
||||||
|
<kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">Enter</kbd> to select
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SearchResultRow(item handlers.BookInfo, query string) {
|
||||||
|
<div class="search-result-item p-3 border-b transition-colors cursor-pointer" style="border-color: var(--border)">
|
||||||
|
<a href={ "/media/" + item.MediaItemID } class="block">
|
||||||
|
<div class="flex items-start space-x-3">
|
||||||
|
<div class="relative w-10 h-14 flex-shrink-0 rounded overflow-hidden bg-gradient-to-br from-gray-700 to-gray-900">
|
||||||
|
<img src={ searchCoverURL(item) } alt={ item.Title } class="w-full h-full object-cover" loading="lazy"/>
|
||||||
|
@SearchProgressOverlay(item)
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">@templ.Raw(highlightQueryHTML(item.Title, query))</h4>
|
||||||
|
if item.Author != "" {
|
||||||
|
<p class="text-xs truncate" style="color: var(--text-secondary)">@templ.Raw(highlightQueryHTML(item.Author, query))</p>
|
||||||
|
}
|
||||||
|
if item.LibraryName != "" {
|
||||||
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">{ item.LibraryName }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchProgressOverlay is the cover overlay shared by the dropdown
|
||||||
|
// rows and BookCard: a completed check at/above the 0.999 threshold,
|
||||||
|
// a partial fill bar along the cover bottom while mid-read, nothing
|
||||||
|
// when the viewer never opened the book.
|
||||||
|
templ SearchProgressOverlay(item handlers.BookInfo) {
|
||||||
|
if item.ViewerPercentage != nil {
|
||||||
|
if *item.ViewerPercentage >= CompletedProgressThreshold {
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 z-10 flex items-center justify-center h-4 w-4 rounded-full shadow-sm"
|
||||||
|
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||||
|
title="Completed"
|
||||||
|
>
|
||||||
|
@Icon("check", "h-3 w-3")
|
||||||
|
</span>
|
||||||
|
} else if *item.ViewerPercentage > 0 {
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 z-10 h-1" style="background-color: color-mix(in srgb, var(--bg-primary) 60%, transparent);">
|
||||||
|
<div class="h-full" style={ progressFillCSS(*item.ViewerPercentage) }></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resultCountLabel(n int, query string) string {
|
||||||
|
if n == 1 {
|
||||||
|
return fmt.Sprintf("1 result for “%s”", query)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d results for “%s”", n, query)
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.1020
|
||||||
|
package templates
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompletedProgressThreshold is the viewer_percentage at which a card
|
||||||
|
// shows the completed check instead of a partial-progress bar — the
|
||||||
|
// SAME 0.999 cut the filter_query.go status arms and the series-page
|
||||||
|
// status filter use.
|
||||||
|
const CompletedProgressThreshold = 0.999
|
||||||
|
|
||||||
|
// progressFillCSS renders the partial-progress bar width (gotcha #8:
|
||||||
|
// quote-free style values only — templ double-escapes quotes into
|
||||||
|
// literal text the CSS parser never sees).
|
||||||
|
func progressFillCSS(pct float64) string {
|
||||||
|
return fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", pct*100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// highlightQueryHTML escapes text FIRST and only then wraps
|
||||||
|
// case-insensitive query matches in <mark> — the returned HTML is
|
||||||
|
// safe by construction (escaped payload, marker tags we wrote), which
|
||||||
|
// is what lets the search partial emit it via templ.Raw.
|
||||||
|
func highlightQueryHTML(text, query string) string {
|
||||||
|
if text == "" || query == "" {
|
||||||
|
return html.EscapeString(text)
|
||||||
|
}
|
||||||
|
pattern := regexp.MustCompile(regexp.QuoteMeta(html.EscapeString(query)))
|
||||||
|
markOpen := `<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">`
|
||||||
|
return pattern.ReplaceAllString(html.EscapeString(text), markOpen+"$0</mark>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchCoverURL falls back to the placeholder the way BookCard does —
|
||||||
|
// no inline onerror (CSP: hash-mode refuses native handler attributes).
|
||||||
|
func searchCoverURL(item handlers.BookInfo) string {
|
||||||
|
if item.CoverImagePath != "" {
|
||||||
|
return item.CoverImagePath
|
||||||
|
}
|
||||||
|
return "/static/placeholder-book.svg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchResults is the header-search dropdown partial: compact result
|
||||||
|
// rows swapped into #search-results by the htmx request the header
|
||||||
|
// input fires (layout=dropdown on /api/media-items/search). Content is
|
||||||
|
// server-rendered (rendering-ownership doctrine); web/src/search.ts
|
||||||
|
// only owns open/close/keyboard state.
|
||||||
|
func SearchResults(items []handlers.BookInfo, query string) 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_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"p-3 border-b\" style=\"border-color: var(--border)\"><p class=\"text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 string
|
||||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(resultCountLabel(len(items), query))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/search_results.templ`, Line: 54, Col: 40}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"p-4 text-center\"><div class=\"text-4xl mb-2\">🔍</div><p class=\"text-sm\" style=\"color: var(--text-primary)\">No results found</p><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Try different keywords</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"max-h-96 overflow-y-auto\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
templ_7745c5c3_Err = SearchResultRow(item, query).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div class=\"p-2 border-t text-center\" style=\"border-color: var(--border)\"><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Press <kbd class=\"px-1 py-0.5 rounded\" style=\"background-color: var(--bg-primary)\">↑↓</kbd> to navigate, <kbd class=\"px-1 py-0.5 rounded\" style=\"background-color: var(--bg-primary)\">Enter</kbd> to select</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchResultRow(item handlers.BookInfo, query string) 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_Var3 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var3 == nil {
|
||||||
|
templ_7745c5c3_Var3 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"search-result-item p-3 border-b transition-colors cursor-pointer\" style=\"border-color: var(--border)\"><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/search_results.templ`, Line: 80, Col: 40}
|
||||||
|
}
|
||||||
|
_, 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, 7, "\" class=\"block\"><div class=\"flex items-start space-x-3\"><div class=\"relative w-10 h-14 flex-shrink-0 rounded overflow-hidden bg-gradient-to-br from-gray-700 to-gray-900\"><img src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(searchCoverURL(item))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/search_results.templ`, Line: 83, Col: 36}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" alt=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/search_results.templ`, Line: 83, Col: 55}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"w-full h-full object-cover\" loading=\"lazy\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = SearchProgressOverlay(item).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div><div class=\"flex-1 min-w-0\"><h4 class=\"text-sm font-medium truncate\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templ.Raw(highlightQueryHTML(item.Title, query)).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</h4>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if item.Author != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<p class=\"text-xs truncate\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templ.Raw(highlightQueryHTML(item.Author, query)).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.LibraryName != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.LibraryName)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/search_results.templ`, Line: 92, Col: 85}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div></a></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchProgressOverlay is the cover overlay shared by the dropdown
|
||||||
|
// rows and BookCard: a completed check at/above the 0.999 threshold,
|
||||||
|
// a partial fill bar along the cover bottom while mid-read, nothing
|
||||||
|
// when the viewer never opened the book.
|
||||||
|
func SearchProgressOverlay(item handlers.BookInfo) 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_Var8 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var8 == nil {
|
||||||
|
templ_7745c5c3_Var8 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
if item.ViewerPercentage != nil {
|
||||||
|
if *item.ViewerPercentage >= CompletedProgressThreshold {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"absolute top-0.5 left-0.5 z-10 flex items-center justify-center h-4 w-4 rounded-full shadow-sm\" style=\"background-color: var(--accent); color: var(--bg-primary);\" title=\"Completed\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("check", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if *item.ViewerPercentage > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"absolute bottom-0 left-0 right-0 z-10 h-1\" style=\"background-color: color-mix(in srgb, var(--bg-primary) 60%, transparent);\"><div class=\"h-full\" style=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(progressFillCSS(*item.ViewerPercentage))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/search_results.templ`, Line: 116, Col: 71}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func resultCountLabel(n int, query string) string {
|
||||||
|
if n == 1 {
|
||||||
|
return fmt.Sprintf("1 result for “%s”", query)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d results for “%s”", n, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
+164
-5
@@ -3,9 +3,69 @@ package templates
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, errorMessage string) {
|
// seriesSortOptions is the <select> mirror of the SQL CASE cascade in
|
||||||
|
// GetDistinctSeries and the registry in services.ResolveSeriesSort —
|
||||||
|
// every value here must have a matching base sort in
|
||||||
|
// services.seriesBaseSorts (the direction toggle composes the full
|
||||||
|
// registry key).
|
||||||
|
var seriesSortOptions = []struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
}{
|
||||||
|
{"recent", "Date added"},
|
||||||
|
{"name", "Name"},
|
||||||
|
{"author", "Author"},
|
||||||
|
{"books", "Books"},
|
||||||
|
{"progress", "Progress"},
|
||||||
|
{"last_read", "Last read"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesStatusOptions is the progress-filter <select> (mirror of the
|
||||||
|
// status registry in services.ResolveSeriesSort's sibling map).
|
||||||
|
var seriesStatusOptions = []struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
}{
|
||||||
|
{"", "Any progress"},
|
||||||
|
{"unstarted", "Unstarted"},
|
||||||
|
{"reading", "In progress"},
|
||||||
|
{"finished", "Finished"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesPageURL builds a /series URL carrying the full page state so
|
||||||
|
// pagination and clear-filters links never drop sort/filter state.
|
||||||
|
func seriesPageURL(state SeriesPageState, libraryID string, page int) string {
|
||||||
|
vals := url.Values{}
|
||||||
|
if libraryID != "" {
|
||||||
|
vals.Set("library_id", libraryID)
|
||||||
|
}
|
||||||
|
if state.Sort != "" {
|
||||||
|
vals.Set("sort", state.Sort)
|
||||||
|
}
|
||||||
|
if state.Dir != "" {
|
||||||
|
vals.Set("dir", state.Dir)
|
||||||
|
}
|
||||||
|
if state.Query != "" {
|
||||||
|
vals.Set("q", state.Query)
|
||||||
|
}
|
||||||
|
if state.Status != "" {
|
||||||
|
vals.Set("status", state.Status)
|
||||||
|
}
|
||||||
|
vals.Set("page", strconv.Itoa(page))
|
||||||
|
return "/series?" + vals.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasActiveSeriesFilters reports whether the state deviates from the
|
||||||
|
// page's defaults — used to swap the empty state for a
|
||||||
|
// "no matches" + clear-filters variant.
|
||||||
|
func hasActiveSeriesFilters(state SeriesPageState) bool {
|
||||||
|
return state.Query != "" || state.Status != "" || (state.Sort != "" && state.Sort != "recent")
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, state SeriesPageState, errorMessage string) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -17,7 +77,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
|||||||
</head>
|
</head>
|
||||||
<body x-data="seriesPage" x-init="initSeriesPage()" class={ "theme-" + user.Theme }>
|
<body x-data="seriesPage" x-init="initSeriesPage()" class={ "theme-" + user.Theme }>
|
||||||
@Header(user, "/series")
|
@Header(user, "/series")
|
||||||
@LibrarySwitcher(libData, currentLibraryID, "")
|
@LibrarySwitcher(libData, currentLibraryID, "", nil)
|
||||||
<div id="series-container">
|
<div id="series-container">
|
||||||
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-6 flex items-center gap-3">
|
<div class="mb-6 flex items-center gap-3">
|
||||||
@@ -29,7 +89,78 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
|||||||
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Grouped by series metadata</p>
|
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Grouped by series metadata</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<form
|
||||||
|
id="series-filter-form"
|
||||||
|
method="get"
|
||||||
|
action="/series"
|
||||||
|
class="mb-6 flex flex-wrap items-center gap-3"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="library_id" value={ currentLibraryID }/>
|
||||||
|
<div class="relative flex-1 min-w-48">
|
||||||
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary)">
|
||||||
|
@Icon("search", "h-4 w-4")
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
value={ state.Query }
|
||||||
|
placeholder="Search series, authors, titles…"
|
||||||
|
class="input w-full pl-9"
|
||||||
|
aria-label="Search series by name, author, or title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary" aria-label="Search">
|
||||||
|
@Icon("search", "h-4 w-4")
|
||||||
|
<span>Search</span>
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
class="input w-auto cursor-pointer"
|
||||||
|
aria-label="Filter series by your progress"
|
||||||
|
@change="$el.form.requestSubmit()"
|
||||||
|
>
|
||||||
|
for _, opt := range seriesStatusOptions {
|
||||||
|
<option value={ opt.Value } selected?={ state.Status == opt.Value }>{ opt.Label }</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
name="sort"
|
||||||
|
class="input w-auto cursor-pointer"
|
||||||
|
aria-label="Sort series by"
|
||||||
|
@change="$el.form.requestSubmit()"
|
||||||
|
>
|
||||||
|
for _, opt := range seriesSortOptions {
|
||||||
|
<option value={ opt.Value } selected?={ (state.Sort == "" && opt.Value == "recent") || state.Sort == opt.Value }>{ opt.Label }</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<input type="hidden" name="dir" value={ state.Dir } x-ref="sortDir"/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary px-3"
|
||||||
|
aria-label="Toggle sort direction"
|
||||||
|
title="Toggle sort direction"
|
||||||
|
@click="flipSortDir()"
|
||||||
|
>
|
||||||
|
if state.Dir == "asc" {
|
||||||
|
@Icon("chevron-up", "h-4 w-4")
|
||||||
|
} else {
|
||||||
|
@Icon("chevron-down", "h-4 w-4")
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
if len(seriesList) == 0 {
|
if len(seriesList) == 0 {
|
||||||
|
if hasActiveSeriesFilters(state) {
|
||||||
|
<div id="series-empty" class="card text-center py-16 px-6">
|
||||||
|
<div class="grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("search", "h-7 w-7")
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Series Match</h3>
|
||||||
|
<p class="mb-4" style="color: var(--text-secondary)">Nothing matches the current search or filters</p>
|
||||||
|
<a href={ seriesPageURL(SeriesPageState{}, currentLibraryID, 1) } class="btn btn-secondary">
|
||||||
|
Clear filters
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
<div id="series-empty" class="card text-center py-16 px-6">
|
<div id="series-empty" class="card text-center py-16 px-6">
|
||||||
<div class="grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
<div class="grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
@Icon("layers", "h-7 w-7")
|
@Icon("layers", "h-7 w-7")
|
||||||
@@ -38,7 +169,8 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
|||||||
<p style="color: var(--text-secondary)">Books with series metadata will appear here</p>
|
<p style="color: var(--text-secondary)">Books with series metadata will appear here</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div id="series-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
}
|
||||||
|
<div id="series-grid" class="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||||
for _, series := range seriesList {
|
for _, series := range seriesList {
|
||||||
@SeriesCard(series)
|
@SeriesCard(series)
|
||||||
}
|
}
|
||||||
@@ -47,7 +179,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
|||||||
<div id="series-pagination" class="flex justify-center items-center gap-3 mt-10">
|
<div id="series-pagination" class="flex justify-center items-center gap-3 mt-10">
|
||||||
if currentPage > 1 {
|
if currentPage > 1 {
|
||||||
<a
|
<a
|
||||||
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1) }
|
href={ seriesPageURL(state, currentLibraryID, currentPage-1) }
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
>
|
>
|
||||||
@Icon("chevron-left", "h-4 w-4")
|
@Icon("chevron-left", "h-4 w-4")
|
||||||
@@ -59,7 +191,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
|||||||
</span>
|
</span>
|
||||||
if currentPage < totalPages {
|
if currentPage < totalPages {
|
||||||
<a
|
<a
|
||||||
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1) }
|
href={ seriesPageURL(state, currentLibraryID, currentPage+1) }
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
>
|
>
|
||||||
<span>Next</span>
|
<span>Next</span>
|
||||||
@@ -99,7 +231,34 @@ templ SeriesCard(series SeriesCardData) {
|
|||||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
||||||
{ fmt.Sprintf("%d", series.BookCount) } of { fmt.Sprintf("%d", series.TotalInSeries) } books
|
{ fmt.Sprintf("%d", series.BookCount) } of { fmt.Sprintf("%d", series.TotalInSeries) } books
|
||||||
</p>
|
</p>
|
||||||
|
if series.ReadCount > 0 {
|
||||||
|
<div class="mt-1.5 h-1 rounded-full overflow-hidden" style="background: var(--bg-primary);">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full"
|
||||||
|
style={ fmt.Sprintf("width: %d%%; background-color: var(--accent);", readPercent(series.ReadCount, series.BookCount)) }
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] mt-0.5" style="color: var(--text-secondary)">
|
||||||
|
{ fmt.Sprintf("%d of %d read", series.ReadCount, series.BookCount) }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readPercent converts (finished, total) into a clamped 0..100 int for
|
||||||
|
// the card's progress bar.
|
||||||
|
func readPercent(finished, total int64) int {
|
||||||
|
if total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
pct := int(finished * 100 / total)
|
||||||
|
if pct < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if pct > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return pct
|
||||||
|
}
|
||||||
+389
-87
@@ -11,9 +11,69 @@ import templruntime "github.com/a-h/templ/runtime"
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, errorMessage string) templ.Component {
|
// seriesSortOptions is the <select> mirror of the SQL CASE cascade in
|
||||||
|
// GetDistinctSeries and the registry in services.ResolveSeriesSort —
|
||||||
|
// every value here must have a matching base sort in
|
||||||
|
// services.seriesBaseSorts (the direction toggle composes the full
|
||||||
|
// registry key).
|
||||||
|
var seriesSortOptions = []struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
}{
|
||||||
|
{"recent", "Date added"},
|
||||||
|
{"name", "Name"},
|
||||||
|
{"author", "Author"},
|
||||||
|
{"books", "Books"},
|
||||||
|
{"progress", "Progress"},
|
||||||
|
{"last_read", "Last read"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesStatusOptions is the progress-filter <select> (mirror of the
|
||||||
|
// status registry in services.ResolveSeriesSort's sibling map).
|
||||||
|
var seriesStatusOptions = []struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
}{
|
||||||
|
{"", "Any progress"},
|
||||||
|
{"unstarted", "Unstarted"},
|
||||||
|
{"reading", "In progress"},
|
||||||
|
{"finished", "Finished"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesPageURL builds a /series URL carrying the full page state so
|
||||||
|
// pagination and clear-filters links never drop sort/filter state.
|
||||||
|
func seriesPageURL(state SeriesPageState, libraryID string, page int) string {
|
||||||
|
vals := url.Values{}
|
||||||
|
if libraryID != "" {
|
||||||
|
vals.Set("library_id", libraryID)
|
||||||
|
}
|
||||||
|
if state.Sort != "" {
|
||||||
|
vals.Set("sort", state.Sort)
|
||||||
|
}
|
||||||
|
if state.Dir != "" {
|
||||||
|
vals.Set("dir", state.Dir)
|
||||||
|
}
|
||||||
|
if state.Query != "" {
|
||||||
|
vals.Set("q", state.Query)
|
||||||
|
}
|
||||||
|
if state.Status != "" {
|
||||||
|
vals.Set("status", state.Status)
|
||||||
|
}
|
||||||
|
vals.Set("page", strconv.Itoa(page))
|
||||||
|
return "/series?" + vals.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasActiveSeriesFilters reports whether the state deviates from the
|
||||||
|
// page's defaults — used to swap the empty state for a
|
||||||
|
// "no matches" + clear-filters variant.
|
||||||
|
func hasActiveSeriesFilters(state SeriesPageState) bool {
|
||||||
|
return state.Query != "" || state.Status != "" || (state.Sort != "" && state.Sort != "recent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, state SeriesPageState, errorMessage string) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -64,7 +124,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = LibrarySwitcher(libData, currentLibraryID, "").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = LibrarySwitcher(libData, currentLibraryID, "", nil).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -76,12 +136,201 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Series</h1><p class=\"text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Grouped by series metadata</p></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Series</h1><p class=\"text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Grouped by series metadata</p></div></div><form id=\"series-filter-form\" method=\"get\" action=\"/series\" class=\"mb-6 flex flex-wrap items-center gap-3\"><input type=\"hidden\" name=\"library_id\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentLibraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 98, Col: 69}
|
||||||
|
}
|
||||||
|
_, 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, 6, "\"><div class=\"relative flex-1 min-w-48\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("search", "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, 7, "</span> <input type=\"text\" name=\"q\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(state.Query)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 106, Col: 27}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" placeholder=\"Search series, authors, titles…\" class=\"input w-full pl-9\" aria-label=\"Search series by name, author, or title\"></div><button type=\"submit\" class=\"btn btn-secondary\" aria-label=\"Search\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("search", "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, 9, "<span>Search</span></button> <select name=\"status\" class=\"input w-auto cursor-pointer\" aria-label=\"Filter series by your progress\" @change=\"$el.form.requestSubmit()\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, opt := range seriesStatusOptions {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Value)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 123, Col: 33}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if state.Status == opt.Value {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 123, Col: 87}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</select> <select name=\"sort\" class=\"input w-auto cursor-pointer\" aria-label=\"Sort series by\" @change=\"$el.form.requestSubmit()\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, opt := range seriesSortOptions {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Value)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 133, Col: 33}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if (state.Sort == "" && opt.Value == "recent") || state.Sort == opt.Value {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 133, Col: 132}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</select> <input type=\"hidden\" name=\"dir\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(state.Dir)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 136, Col: 55}
|
||||||
|
}
|
||||||
|
_, 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, 22, "\" x-ref=\"sortDir\"> <button type=\"button\" class=\"btn btn-secondary px-3\" aria-label=\"Toggle sort direction\" title=\"Toggle sort direction\" @click=\"flipSortDir()\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if state.Dir == "asc" {
|
||||||
|
templ_7745c5c3_Err = Icon("chevron-up", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = Icon("chevron-down", "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, 23, "</button></form>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(seriesList) == 0 {
|
if len(seriesList) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div id=\"series-empty\" class=\"card text-center py-16 px-6\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
if hasActiveSeriesFilters(state) {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div id=\"series-empty\" class=\"card text-center py-16 px-6\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("search", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Series Match</h3><p class=\"mb-4\" style=\"color: var(--text-secondary)\">Nothing matches the current search or filters</p><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(seriesPageURL(SeriesPageState{}, currentLibraryID, 1))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 159, Col: 71}
|
||||||
|
}
|
||||||
|
_, 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, 26, "\" class=\"btn btn-secondary\">Clear filters</a></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div id=\"series-empty\" class=\"card text-center py-16 px-6\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -89,12 +338,13 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Series Found</h3><p style=\"color: var(--text-secondary)\">Books with series metadata will appear here</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Series Found</h3><p style=\"color: var(--text-secondary)\">Books with series metadata will appear here</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div id=\"series-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div id=\"series-grid\" class=\"grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -104,30 +354,30 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if totalPages > 1 {
|
if totalPages > 1 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div id=\"series-pagination\" class=\"flex justify-center items-center gap-3 mt-10\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div id=\"series-pagination\" class=\"flex justify-center items-center gap-3 mt-10\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if currentPage > 1 {
|
if currentPage > 1 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 templ.SafeURL
|
var templ_7745c5c3_Var12 templ.SafeURL
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1))
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(seriesPageURL(state, currentLibraryID, currentPage-1))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 50, Col: 93}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 182, Col: 69}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"btn btn-secondary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"btn btn-secondary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -135,56 +385,56 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span>Previous</span></a> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<span>Previous</span></a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"text-sm\" style=\"color: var(--text-secondary)\">Page ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span class=\"text-sm\" style=\"color: var(--text-secondary)\">Page ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", currentPage))
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", currentPage))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 58, Col: 45}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 190, Col: 45}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " of ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " of ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages))
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 58, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 190, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if currentPage < totalPages {
|
if currentPage < totalPages {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 templ.SafeURL
|
var templ_7745c5c3_Var15 templ.SafeURL
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1))
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(seriesPageURL(state, currentLibraryID, currentPage+1))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 62, Col: 93}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 194, Col: 69}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"btn btn-secondary\"><span>Next</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" class=\"btn btn-secondary\"><span>Next</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -192,17 +442,17 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</main></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</main></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -210,7 +460,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -234,102 +484,102 @@ func SeriesCard(series SeriesCardData) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var16 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var8 == nil {
|
if templ_7745c5c3_Var16 == nil {
|
||||||
templ_7745c5c3_Var8 = templ.NopComponent
|
templ_7745c5c3_Var16 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 templ.SafeURL
|
var templ_7745c5c3_Var17 templ.SafeURL
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(series.Name) + "&library_id=" + url.QueryEscape(series.LibraryID))
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(series.Name) + "&library_id=" + url.QueryEscape(series.LibraryID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 79, Col: 117}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 211, Col: 117}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"block\"><div class=\"series-card card rounded-2xl overflow-hidden cursor-pointer\" style=\"box-shadow: var(--shadow-card);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" class=\"block\"><div class=\"series-card card rounded-2xl overflow-hidden cursor-pointer\" style=\"box-shadow: var(--shadow-card);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 = []any{"stacked-covers cover-count-" + fmt.Sprintf("%d", len(series.CoverPaths))}
|
var templ_7745c5c3_Var18 = []any{"stacked-covers cover-count-" + fmt.Sprintf("%d", len(series.CoverPaths))}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String())
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for i, cover := range series.CoverPaths {
|
for i, cover := range series.CoverPaths {
|
||||||
var templ_7745c5c3_Var12 = []any{"cover-img cover-" + fmt.Sprintf("%d", i)}
|
var templ_7745c5c3_Var20 = []any{"cover-img cover-" + fmt.Sprintf("%d", i)}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<img src=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<img src=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(cover)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(cover)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 84, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 216, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var12).String())
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var20).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" alt=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" alt=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(series.Name)
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(series.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 86, Col: 23}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 218, Col: 23}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" loading=\"lazy\"> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" loading=\"lazy\"> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(series.CoverPaths) == 0 {
|
if len(series.CoverPaths) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"cover-img cover-0 flex items-center justify-center\" style=\"background: var(--bg-primary); color: var(--text-secondary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div class=\"cover-img cover-0 flex items-center justify-center\" style=\"background: var(--bg-primary); color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -337,51 +587,87 @@ func SeriesCard(series SeriesCardData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div><div class=\"series-card-info px-3 py-2\"><h3 class=\"font-semibold text-sm line-clamp-2\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</div><div class=\"series-card-info px-3 py-2\"><h3 class=\"font-semibold text-sm line-clamp-2\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 98, Col: 99}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 230, Col: 99}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</h3><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</h3><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.BookCount))
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.BookCount))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 100, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 232, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " of ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " of ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var26 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.TotalInSeries))
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.TotalInSeries))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 100, Col: 89}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 232, Col: 89}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " books</p></div></div></a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " books</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if series.ReadCount > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"mt-1.5 h-1 rounded-full overflow-hidden\" style=\"background: var(--bg-primary);\"><div class=\"h-full rounded-full\" style=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var27 string
|
||||||
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %d%%; background-color: var(--accent);", readPercent(series.ReadCount, series.BookCount)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 238, Col: 124}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\"></div></div><p class=\"text-[10px] mt-0.5\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var28 string
|
||||||
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d of %d read", series.ReadCount, series.BookCount))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 242, Col: 72}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</div></div></a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -389,4 +675,20 @@ func SeriesCard(series SeriesCardData) templ.Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readPercent converts (finished, total) into a clamped 0..100 int for
|
||||||
|
// the card's progress bar.
|
||||||
|
func readPercent(finished, total int64) int {
|
||||||
|
if total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
pct := int(finished * 100 / total)
|
||||||
|
if pct < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if pct > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return pct
|
||||||
|
}
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
var _ = templruntime.GeneratedTemplate
|
||||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user