Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f6a73ab5aa | ||
|
|
afbf1029a9 | ||
|
|
c645b8e207 | ||
|
|
bdb9132b85 |
No files matched your search
@@ -4,6 +4,32 @@ Standing rules for any agent or developer working in this repo.
|
||||
Project-wide context lives in the sibling repos' AGENTS.md files
|
||||
(../bookhoard-app/AGENTS.md is the Android client's).
|
||||
|
||||
## Commit as each unit of work completes — ALWAYS apply
|
||||
|
||||
Commit when a logically complete piece of the project is done — a fix,
|
||||
a feature slice, a schema change, a docs/test batch — before moving on
|
||||
to the next piece. Do not hold a whole session's work for one giant
|
||||
end-of-session commit, and never push uncommitted work aside until the
|
||||
tree is clean.
|
||||
|
||||
- **One commit per related edit.** If the changed files serve more than
|
||||
one purpose, they are separate commits; only share a commit when you
|
||||
are sure every file in it is the same related edit. Generated
|
||||
artifacts (templ codegen, sqlc output, vite/tailwind build output,
|
||||
bruno/openapi mirrors) commit with the source change that produced
|
||||
them.
|
||||
- **Detailed messages**, in the repo's conventional-commit style
|
||||
(`fix(scanner): …`, `feat(issues): …`) with a body that says WHY the
|
||||
change exists, what behavior changed, and any precedent it follows —
|
||||
not just what the diff shows. The body is how the next agent (and
|
||||
git history) arbitrates "test stale vs code changed".
|
||||
- **Ordering**: stack commits so each tree compiles where practical
|
||||
(a service before the handler that calls it, a guard before the
|
||||
feature that relies on it).
|
||||
- **Committing ≠ pushing.** Commits are local bookkeeping and land as
|
||||
work completes; pushing (once, after the last commit) is the author's
|
||||
call. Never push half-finished units just because they were committed.
|
||||
|
||||
## Rendering ownership (templ vs TypeScript) — ALWAYS apply
|
||||
|
||||
The point of templ + htmx + Alpine is: the server renders the UI, and
|
||||
@@ -54,7 +80,11 @@ refuses, **silently at build/test time**, two authoring patterns:
|
||||
guard means recomputing the hash (the failing test prints it); adding
|
||||
a *second* inline script means hashing it too and extending
|
||||
`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=`,
|
||||
`onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline
|
||||
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:
|
||||
name: Scan Library
|
||||
type: http
|
||||
seq: 18
|
||||
seq: 19
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Set Library Visibility
|
||||
type: http
|
||||
seq: 19
|
||||
seq: 20
|
||||
|
||||
http:
|
||||
method: POST
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Update Library
|
||||
type: http
|
||||
seq: 20
|
||||
seq: 21
|
||||
|
||||
http:
|
||||
method: PUT
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
info:
|
||||
name: Update Scan Settings
|
||||
type: http
|
||||
seq: 21
|
||||
seq: 22
|
||||
|
||||
http:
|
||||
method: PUT
|
||||
|
||||
@@ -0,0 +1,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/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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.Equal(t, "Tag Narrator", item.Narrators[0])
|
||||
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.Equal(t, 2020, item.DatePublished.Time.Year())
|
||||
// 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.
|
||||
// 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{
|
||||
"media_item_id", "title", "author", "cover_image_path",
|
||||
"has_conflict", "format_group", "badge",
|
||||
"viewer_percentage", "library_name",
|
||||
}
|
||||
|
||||
func collectionURL(setup *TestServerSetup, id string) string {
|
||||
@@ -667,6 +671,12 @@ func TestContractPin_SearchJSON(t *testing.T) {
|
||||
// (docs/contract_pins.md changelog).
|
||||
"subtitle", "explicit", "authors", "authors_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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// unlinkedBooksFixture is one user + device + two queue rows. The regular
|
||||
// user's cleanup cascades: users → devices → unlinked_books.
|
||||
type unlinkedBooksFixture struct {
|
||||
setup *TestServerSetup
|
||||
owner UserTestData
|
||||
ownerTok string
|
||||
deviceID pgtype.UUID
|
||||
contentA string
|
||||
contentB string
|
||||
rowA pgtype.UUID
|
||||
rowB pgtype.UUID
|
||||
}
|
||||
|
||||
func setupUnlinkedBooksFixture(t *testing.T) *unlinkedBooksFixture {
|
||||
f := &unlinkedBooksFixture{setup: setupTestServer(t), contentA: "test-content-aaa", contentB: "test-content-bbb"}
|
||||
f.owner = createRegularUserOnce(t, f.setup.DB)
|
||||
f.ownerTok = loginUserWithCredentials(t, f.setup.Server, f.owner.Email, f.owner.Password)
|
||||
|
||||
device, err := f.setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
|
||||
UserID: pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true},
|
||||
DeviceName: "test-unlinked-reader",
|
||||
DeviceType: "koreader",
|
||||
DeviceIdentifier: "test-unlinked-" + uuid.New().String()[:8],
|
||||
AuthToken: "dev_test_" + uuid.New().String(),
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
require.NoError(t, err, "device for unlinked-books tests")
|
||||
f.deviceID = device.ID
|
||||
|
||||
f.rowA = f.seedRow(t, f.contentA, "Test Book A")
|
||||
f.rowB = f.seedRow(t, f.contentB, "Test Book B")
|
||||
return f
|
||||
}
|
||||
|
||||
// seedRow inserts one queue row for the fixture device.
|
||||
func (f *unlinkedBooksFixture) seedRow(t *testing.T, contentID, title string) pgtype.UUID {
|
||||
row, err := f.setup.DB.CreateUnlinkedBook(context.Background(), database.CreateUnlinkedBookParams{
|
||||
DeviceID: f.deviceID,
|
||||
ContentID: contentID,
|
||||
Title: pgtype.Text{String: title, Valid: true},
|
||||
})
|
||||
require.NoError(t, err, "seed unlinked_books row")
|
||||
return row.ID
|
||||
}
|
||||
|
||||
// doAction fires an htmx-style page action as the given user.
|
||||
func (f *unlinkedBooksFixture) doAction(t *testing.T, token, method, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Cookie", "token="+token)
|
||||
req.Header.Set("HX-Request", "true")
|
||||
rec := httptest.NewRecorder()
|
||||
f.setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (f *unlinkedBooksFixture) unresolvedCount(t *testing.T) int {
|
||||
rows, err := f.setup.DB.ListUnresolvedUnlinkedBooksForUser(context.Background(),
|
||||
pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
|
||||
require.NoError(t, err)
|
||||
return len(rows)
|
||||
}
|
||||
|
||||
func (f *unlinkedBooksFixture) ignoredIDs(t *testing.T) []uuid.UUID {
|
||||
rows, err := f.setup.DB.ListIgnoredUnlinkedBooksForUser(context.Background(),
|
||||
pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
|
||||
require.NoError(t, err)
|
||||
ids := make([]uuid.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, uuid.UUID(r.ID.Bytes))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// TestUnlinkedBooksIgnoreLifecycle pins the soft-dismiss contract: hidden
|
||||
// from the page list, badge count, and per-device API; preserved across
|
||||
// the sync upsert; reversible via unignore. Hard delete is covered too.
|
||||
func TestUnlinkedBooksIgnoreLifecycle(t *testing.T) {
|
||||
f := setupUnlinkedBooksFixture(t)
|
||||
ctx := context.Background()
|
||||
pathA := "/unlinked-books/" + uuid.UUID(f.rowA.Bytes).String()
|
||||
pathB := "/unlinked-books/" + uuid.UUID(f.rowB.Bytes).String()
|
||||
|
||||
t.Run("ignore hides the row everywhere", func(t *testing.T) {
|
||||
rec := f.doAction(t, f.ownerTok, "POST", pathA+"/ignore")
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
// The htmx swap re-renders the whole section partial.
|
||||
assert.Contains(t, rec.Body.String(), `id="unlinked-section"`)
|
||||
|
||||
assert.Equal(t, 1, f.unresolvedCount(t), "row B remains queued")
|
||||
ignored := f.ignoredIDs(t)
|
||||
require.Len(t, ignored, 1)
|
||||
assert.Equal(t, uuid.UUID(f.rowA.Bytes), ignored[0])
|
||||
|
||||
count, err := f.setup.DB.CountUnresolvedUnlinkedBooksForUser(ctx, pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), count, "dashboard badge must still count row B only")
|
||||
|
||||
perDevice, err := f.setup.DB.GetUnlinkedBooksByDevice(ctx, f.deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, perDevice, 1, "per-device API must hide the ignored row")
|
||||
deviceCount, err := f.setup.DB.CountUnlinkedBooks(ctx, f.deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), deviceCount)
|
||||
})
|
||||
|
||||
t.Run("device sync refresh preserves the ignore", func(t *testing.T) {
|
||||
// The exact query the KOReader sync path runs on every re-sync.
|
||||
_, err := f.setup.DB.UpsertUnlinkedBook(ctx, database.UpsertUnlinkedBookParams{
|
||||
DeviceID: f.deviceID,
|
||||
ContentID: f.contentA,
|
||||
Title: pgtype.Text{String: "Test Book A (restored metadata)", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
row, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, row.IgnoredAt.Valid, "upsert must never clear ignored_at")
|
||||
assert.Equal(t, "Test Book A (restored metadata)", row.Title.String, "upsert still refreshes metadata")
|
||||
assert.Equal(t, 1, f.unresolvedCount(t), "ignored row must not re-enter the queue")
|
||||
})
|
||||
|
||||
t.Run("unignore restores the row to the queue", func(t *testing.T) {
|
||||
rec := f.doAction(t, f.ownerTok, "POST", pathA+"/unignore")
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
|
||||
assert.Equal(t, 2, f.unresolvedCount(t))
|
||||
assert.Empty(t, f.ignoredIDs(t))
|
||||
row, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, row.IgnoredAt.Valid)
|
||||
})
|
||||
|
||||
t.Run("delete removes the row outright", func(t *testing.T) {
|
||||
rec := f.doAction(t, f.ownerTok, "DELETE", pathB)
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
|
||||
_, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowB)
|
||||
assert.Error(t, err, "hard-deleted row must be gone")
|
||||
assert.Equal(t, 1, f.unresolvedCount(t))
|
||||
})
|
||||
|
||||
t.Run("unknown and malformed ids are rejected", func(t *testing.T) {
|
||||
rec := f.doAction(t, f.ownerTok, "DELETE", "/unlinked-books/"+uuid.New().String())
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
rec = f.doAction(t, f.ownerTok, "POST", "/unlinked-books/not-a-uuid/ignore")
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// TestUnlinkedBooksOwnership pins row → device → user authorization:
|
||||
// another user's queue rows are untouchable, ignored or deleted.
|
||||
func TestUnlinkedBooksOwnership(t *testing.T) {
|
||||
f := setupUnlinkedBooksFixture(t)
|
||||
|
||||
stranger := createRegularUserOnce(t, f.setup.DB)
|
||||
strangerTok := loginUserWithCredentials(t, f.setup.Server, stranger.Email, stranger.Password)
|
||||
pathA := "/unlinked-books/" + uuid.UUID(f.rowA.Bytes).String()
|
||||
|
||||
rec := f.doAction(t, strangerTok, "POST", pathA+"/ignore")
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
row, err := f.setup.DB.GetUnlinkedBookByID(context.Background(), f.rowA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, row.IgnoredAt.Valid, "foreign ignore must not land")
|
||||
|
||||
rec = f.doAction(t, strangerTok, "DELETE", pathA)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
_, err = f.setup.DB.GetUnlinkedBookByID(context.Background(), f.rowA)
|
||||
assert.NoError(t, err, "foreign delete must not land")
|
||||
}
|
||||
@@ -667,3 +667,92 @@ func TestUpdatePasswordSelfChange(t *testing.T) {
|
||||
assert.Contains(t, rec.Body.String(), "current password required")
|
||||
})
|
||||
}
|
||||
|
||||
// TestProfileKeepScreenAwake pins the keep_screen_awake user preference
|
||||
// end to end: schema default, the "true"/"false" form dropdown contract,
|
||||
// JSON-client omission semantics ("" = untouched), and rejection of
|
||||
// anything else. Uses a dedicated user — never the shared admin.
|
||||
func TestProfileKeepScreenAwake(t *testing.T) {
|
||||
// getProfile fetches the JSON profile and returns keep_screen_awake.
|
||||
getProfile := func(t *testing.T, setup *TestServerSetup, token string) bool {
|
||||
req := httptest.NewRequest("GET", "/api/auth/profile", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var body struct {
|
||||
KeepScreenAwake bool `json:"keep_screen_awake"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
return body.KeepScreenAwake
|
||||
}
|
||||
putProfile := func(t *testing.T, setup *TestServerSetup, token string, fields map[string]interface{}) *httptest.ResponseRecorder {
|
||||
jsonData, _ := json.Marshal(fields)
|
||||
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("fresh user defaults to true", func(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
selfUser := createRegularUserOnce(t, setup.DB)
|
||||
token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password)
|
||||
|
||||
row, err := setup.DB.GetUser(context.Background(), pgtype.UUID{Bytes: selfUser.ID, Valid: true})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, row.KeepScreenAwake, "schema default must keep the column TRUE")
|
||||
assert.True(t, getProfile(t, setup, token), "profile API must report the default")
|
||||
})
|
||||
|
||||
t.Run("false persists and reads back", func(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
selfUser := createRegularUserOnce(t, setup.DB)
|
||||
token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password)
|
||||
|
||||
rec := putProfile(t, setup, token, map[string]interface{}{"keep_screen_awake": "false"})
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.False(t, getProfile(t, setup, token))
|
||||
|
||||
row, err := setup.DB.GetUser(context.Background(), pgtype.UUID{Bytes: selfUser.ID, Valid: true})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, row.KeepScreenAwake)
|
||||
})
|
||||
|
||||
t.Run("flipping back to true persists", func(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
selfUser := createRegularUserOnce(t, setup.DB)
|
||||
token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password)
|
||||
|
||||
require.Equal(t, http.StatusOK, putProfile(t, setup, token,
|
||||
map[string]interface{}{"keep_screen_awake": "false"}).Code)
|
||||
require.Equal(t, http.StatusOK, putProfile(t, setup, token,
|
||||
map[string]interface{}{"keep_screen_awake": "true"}).Code)
|
||||
assert.True(t, getProfile(t, setup, token))
|
||||
})
|
||||
|
||||
t.Run("request omitting the field leaves it untouched", func(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
selfUser := createRegularUserOnce(t, setup.DB)
|
||||
token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password)
|
||||
|
||||
// JSON clients (the Android app) sync other profile fields
|
||||
// without knowing this one — the default-on preference must
|
||||
// survive such a save.
|
||||
rec := putProfile(t, setup, token, map[string]interface{}{"theme": "nord"})
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.True(t, getProfile(t, setup, token))
|
||||
})
|
||||
|
||||
t.Run("invalid value is rejected", func(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
selfUser := createRegularUserOnce(t, setup.DB)
|
||||
token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password)
|
||||
|
||||
rec := putProfile(t, setup, token, map[string]interface{}{"keep_screen_awake": "maybe"})
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.True(t, getProfile(t, setup, token), "rejected save must not change the preference")
|
||||
})
|
||||
}
|
||||
@@ -42,6 +42,11 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
-- mirrors the per-book table's column.
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEFAULT 1.0;
|
||||
|
||||
-- Keep-screen-awake preference: when true, the web reader and audiobook
|
||||
-- player request a Screen Wake Lock so the display doesn't blank/lock
|
||||
-- mid-session. On by default; the profile page's On/Off dropdown owns it.
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS keep_screen_awake BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
-- Create system_settings table
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -169,7 +174,7 @@ CREATE TABLE IF NOT EXISTS media_items (
|
||||
language VARCHAR(10) DEFAULT 'en', -- Language code (ISO 639-1)
|
||||
edition VARCHAR(100), -- Edition information
|
||||
page_count INTEGER, -- Total page count
|
||||
genre VARCHAR(100), -- Genre classification
|
||||
genre TEXT, -- Genre classification (smart-genre shelf OR raw tag free text — Audible category strings exceed 100 chars)
|
||||
copyright_year INTEGER, -- Copyright/publication year
|
||||
goodreads_id VARCHAR(50), -- Goodreads identifier
|
||||
openlibrary_id VARCHAR(50), -- Open Library identifier
|
||||
@@ -274,6 +279,14 @@ CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
||||
|
||||
-- Widen genre for existing databases: the audio tag fallback stores the
|
||||
-- raw file genre atom (Audible category strings exceed 100 chars —
|
||||
-- e.g. "Politicians, Presidents & Heads of State, United States, ..."),
|
||||
-- and VARCHAR(100) rejected the whole import/update at the database
|
||||
-- layer (SQLSTATE 22001). VARCHAR -> TEXT is a metadata-only change,
|
||||
-- safe to re-run (media_highlights position widening precedent).
|
||||
ALTER TABLE media_items ALTER COLUMN genre TYPE TEXT;
|
||||
|
||||
-- Audiobook editorial facts (A2a): narrators are user-editable first-class
|
||||
-- (search/filter/badge surface); abridged rides along; audio_metadata keeps
|
||||
-- the scan-derived tech record (duration/codec/chapters).
|
||||
@@ -1491,6 +1504,12 @@ CREATE TABLE IF NOT EXISTS unlinked_books (
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Soft-dismiss for unlinked_books: ignored rows stay on the device's sync
|
||||
-- queue (UpsertUnlinkedBook keeps refreshing their file metadata, and must
|
||||
-- never clear this column) but are hidden from the page list, badge count,
|
||||
-- and per-device API. Reversible via UnignoreUnlinkedBook.
|
||||
ALTER TABLE unlinked_books ADD COLUMN IF NOT EXISTS ignored_at TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
|
||||
-- One queue row per (device, document): the sync ingest upserts on this pair.
|
||||
|
||||
+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` |
|
||||
| | `POST /api/collections/preview` | bare `{items: BookInfo[]}` |
|
||||
| `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}}` |
|
||||
| `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]}` |
|
||||
| `SSRPages` | `/bookshelf`, `/collections`, `/collections/:id`, `/series`, `/series/detail`, `/tags/detail` | 200 + stable title/substring markers |
|
||||
|
||||
`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)
|
||||
|
||||
@@ -414,3 +415,20 @@ cover_image_path, has_conflict, format_group, badge`.
|
||||
BEFORE Rescan All — force rescan stamps books with the current
|
||||
setting, so bump-first + force = one pass of work; force-first +
|
||||
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
|
||||
|
||||
Enqueues a full scan of one library's configured folders. The library id comes
|
||||
Enqueues a scan of one library's configured folders. The library id comes
|
||||
from the path — no request body is needed (the generic `POST /api/scanner/scan`
|
||||
variant is the one that takes `folder_paths`).
|
||||
|
||||
**Per-folder scope**: an optional body of `{"folder_path": "…"}` restricts the
|
||||
scan to ONE of the library's registered folders (the per-folder Scan buttons
|
||||
in the library panel). The path must be a registered folder — anything else is
|
||||
a **400**. A scoped scan never runs archive reconciliation for the library's
|
||||
other folders, so items outside the scanned folder cannot be marked missing
|
||||
by it.
|
||||
|
||||
Scans are **coalesced**: if a scan for this library is already pending or
|
||||
running you get **409** with the active job id instead of a second scan.
|
||||
|
||||
@@ -29,7 +36,7 @@ running you get **409** with the active job id instead of a second scan.
|
||||
|
||||
| Status | Body | When |
|
||||
| ------ | ---- | ---- |
|
||||
| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` | malformed id / empty library |
|
||||
| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` / `{"error": "folder_path is not one of this library's registered folders"}` | malformed id / empty library / unregistered folder scope |
|
||||
| 404 | `{"error": "library not found or has no folders"}` | unknown library |
|
||||
| 409 | `{"error": "a scan for this library is already pending or running", "active_job_id": "..."}` | scan coalescing |
|
||||
| 500 | `{"error": "failed to enqueue scan job: ..."}` | queue failure |
|
||||
@@ -35,7 +35,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
"username": "john",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"role": "user"
|
||||
"role": "user",
|
||||
"keep_screen_awake": true
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Bookhoard API
|
||||
version: '2026.10.24'
|
||||
version: '2026.10.27'
|
||||
description: |
|
||||
Generated from the canonical API docs (docs/developer/api/**) by
|
||||
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
|
||||
@@ -1141,6 +1141,15 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
"/api/libraries/{id}/issues/{issueId}/retry":
|
||||
post:
|
||||
tags: [libraries]
|
||||
summary: "Retry Processing Issue"
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
"/api/libraries/{id}/issues/{issueId}/{mediaItemId}/resolve":
|
||||
post:
|
||||
tags: [libraries]
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- [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");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -562,6 +562,7 @@ type UnlinkedBooks struct {
|
||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
||||
}
|
||||
|
||||
type UserDashboardPreferences struct {
|
||||
@@ -597,4 +598,5 @@ type Users struct {
|
||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
||||
}
|
||||
@@ -53,9 +53,15 @@ type Querier interface {
|
||||
// collections page); same live filter as GetCollectionItems, plus the
|
||||
// future-hybrid excluded exclusion and an optional library scope.
|
||||
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
||||
// Count unlinked books for a device
|
||||
// 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)
|
||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||
// Banner count: unresolved queue rows across the caller's own devices.
|
||||
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
||||
// devices. Ignored rows must not nag from the dashboard badge.
|
||||
CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
|
||||
@@ -274,12 +280,24 @@ type Querier interface {
|
||||
// Series are grouped PER LIBRARY: the same series name in two libraries
|
||||
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||
// 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
|
||||
// MAX(created_at). Postgres does not guarantee tie order between
|
||||
// executions, so page boundaries could repeat or drop series. The GROUP BY
|
||||
// keys make the sort total and deterministic.
|
||||
//
|
||||
// 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.
|
||||
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)
|
||||
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
||||
@@ -456,7 +474,9 @@ type Querier interface {
|
||||
GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error)
|
||||
// Get unlinked book by ID
|
||||
GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error)
|
||||
// Get unlinked books for a device
|
||||
// Fetch one row together with its device's owner for authorization.
|
||||
GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error)
|
||||
// Get unlinked books for a device (ignored rows stay hidden)
|
||||
GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error)
|
||||
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
|
||||
@@ -504,6 +524,9 @@ type Querier interface {
|
||||
// static collection paths apply.
|
||||
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
|
||||
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||
// Soft-dismiss one row (ownership is checked by the caller against the
|
||||
// device's user_id before calling this).
|
||||
IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
||||
// Check if book is in collection
|
||||
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
|
||||
@@ -530,6 +553,9 @@ type Querier interface {
|
||||
// archived row's reading data into its active twin instead of waiting for a
|
||||
// file that will never return.
|
||||
ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error)
|
||||
// Page list, ignored bucket: rows the user dismissed, kept for the
|
||||
// collapsible "Ignored" section so a misclick is recoverable.
|
||||
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
|
||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
||||
@@ -553,7 +579,8 @@ type Querier interface {
|
||||
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
||||
// List unresolved unlinked books with pagination
|
||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||
// Page list: unresolved queue rows for the caller's own devices.
|
||||
// Page list: unresolved queue rows for the caller's own devices (ignored
|
||||
// rows are listed by ListIgnoredUnlinkedBooksForUser instead).
|
||||
ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error)
|
||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||
// First consecutive scan that cannot find the file on disk.
|
||||
@@ -637,6 +664,8 @@ type Querier interface {
|
||||
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
|
||||
TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error
|
||||
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
|
||||
// Undo a soft-dismiss.
|
||||
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||
// Update collection
|
||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
||||
@@ -699,6 +728,7 @@ type Querier interface {
|
||||
// Update universal progress
|
||||
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
||||
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
|
||||
UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error
|
||||
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
||||
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
||||
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
|
||||
|
||||
@@ -369,13 +369,39 @@ func (q *Queries) CountCollectionLiveItems(ctx context.Context, arg CountCollect
|
||||
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
|
||||
SELECT COUNT(*) as count
|
||||
FROM unlinked_books
|
||||
WHERE device_id = $1 AND resolved = false
|
||||
WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL
|
||||
`
|
||||
|
||||
// Count unlinked books for a device
|
||||
// Count unlinked books for a device (ignored rows stay hidden)
|
||||
func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, CountUnlinkedBooks, deviceID)
|
||||
var count int64
|
||||
@@ -387,10 +413,11 @@ const CountUnresolvedUnlinkedBooksForUser = `-- name: CountUnresolvedUnlinkedBoo
|
||||
SELECT COUNT(*) as count
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE d.user_id = $1 AND ub.resolved = false
|
||||
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
||||
`
|
||||
|
||||
// Banner count: unresolved queue rows across the caller's own devices.
|
||||
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
||||
// devices. Ignored rows must not nag from the dashboard badge.
|
||||
func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID)
|
||||
var count int64
|
||||
@@ -1956,7 +1983,7 @@ const CreateUnlinkedBook = `-- name: CreateUnlinkedBook :one
|
||||
|
||||
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
|
||||
`
|
||||
|
||||
type CreateUnlinkedBookParams struct {
|
||||
@@ -1996,6 +2023,7 @@ func (q *Queries) CreateUnlinkedBook(ctx context.Context, arg CreateUnlinkedBook
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -5264,21 +5292,71 @@ func (q *Queries) GetDictionaryEntry(ctx context.Context, word string) (Dictiona
|
||||
const GetDistinctSeries = `-- name: GetDistinctSeries :many
|
||||
SELECT library_id, series, COUNT(*) as book_count,
|
||||
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
|
||||
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
|
||||
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
|
||||
LEFT JOIN reading_progress rp
|
||||
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 missing_scan_count = 0
|
||||
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
|
||||
ORDER BY MAX(created_at) DESC, library_id, series
|
||||
LIMIT $4 OFFSET $3
|
||||
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
|
||||
)
|
||||
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 {
|
||||
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
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"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
@@ -5289,20 +5367,35 @@ type GetDistinctSeriesRow struct {
|
||||
BookCount int64 `db:"book_count" json:"book_count"`
|
||||
TotalInSeries interface{} `db:"total_in_series" json:"total_in_series"`
|
||||
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
|
||||
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||
// 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
|
||||
// MAX(created_at). Postgres does not guarantee tie order between
|
||||
// executions, so page boundaries could repeat or drop series. The GROUP BY
|
||||
// keys make the sort total and deterministic.
|
||||
//
|
||||
// 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.
|
||||
func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetDistinctSeries,
|
||||
arg.ViewerUserID,
|
||||
arg.LibraryID,
|
||||
arg.VisibleLibraryIds,
|
||||
arg.SeriesQuery,
|
||||
arg.AuthorQuery,
|
||||
arg.Status,
|
||||
arg.Sort,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
)
|
||||
@@ -5319,6 +5412,8 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
|
||||
&i.BookCount,
|
||||
&i.TotalInSeries,
|
||||
&i.LastEntryAt,
|
||||
&i.FinishedCount,
|
||||
&i.LastReadAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -5331,22 +5426,62 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
|
||||
}
|
||||
|
||||
const GetDistinctSeriesCount = `-- name: GetDistinctSeriesCount :one
|
||||
SELECT COUNT(DISTINCT (library_id, series))::int
|
||||
FROM media_items
|
||||
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
|
||||
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != ''
|
||||
SELECT COUNT(*)::int FROM (
|
||||
SELECT library_id, series
|
||||
FROM media_items
|
||||
LEFT JOIN reading_progress rp
|
||||
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 missing_scan_count = 0
|
||||
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 {
|
||||
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
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) {
|
||||
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
|
||||
err := row.Scan(&column_1)
|
||||
return column_1, err
|
||||
@@ -9809,7 +9944,7 @@ func (q *Queries) GetUniversalProgress(ctx context.Context, arg GetUniversalProg
|
||||
}
|
||||
|
||||
const GetUnlinkedBookByContentId = `-- name: GetUnlinkedBookByContentId :one
|
||||
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2
|
||||
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2
|
||||
`
|
||||
|
||||
type GetUnlinkedBookByContentIdParams struct {
|
||||
@@ -9835,12 +9970,13 @@ func (q *Queries) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinke
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUnlinkedBookByID = `-- name: GetUnlinkedBookByID :one
|
||||
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE id = $1
|
||||
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE id = $1
|
||||
`
|
||||
|
||||
// Get unlinked book by ID
|
||||
@@ -9861,15 +9997,69 @@ func (q *Queries) GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (Unli
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUnlinkedBookWithOwner = `-- name: GetUnlinkedBookWithOwner :one
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.user_id, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE ub.id = $1
|
||||
`
|
||||
|
||||
type GetUnlinkedBookWithOwnerRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||
ContentID string `db:"content_id" json:"content_id"`
|
||||
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
||||
Title pgtype.Text `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
||||
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
DeviceName string `db:"device_name" json:"device_name"`
|
||||
DeviceType string `db:"device_type" json:"device_type"`
|
||||
}
|
||||
|
||||
// Fetch one row together with its device's owner for authorization.
|
||||
func (q *Queries) GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error) {
|
||||
row := q.db.QueryRow(ctx, GetUnlinkedBookWithOwner, id)
|
||||
var i GetUnlinkedBookWithOwnerRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.DeviceID,
|
||||
&i.ContentID,
|
||||
&i.FilePath,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.ConfidenceScore,
|
||||
&i.Resolved,
|
||||
&i.MediaItemID,
|
||||
&i.ResolvedAt,
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
&i.UserID,
|
||||
&i.DeviceName,
|
||||
&i.DeviceType,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUnlinkedBooksByDevice = `-- name: GetUnlinkedBooksByDevice :many
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE ub.device_id = $1 AND ub.resolved = false
|
||||
WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
||||
ORDER BY ub.last_seen_at DESC
|
||||
`
|
||||
|
||||
@@ -9887,11 +10077,12 @@ type GetUnlinkedBooksByDeviceRow struct {
|
||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
||||
DeviceName string `db:"device_name" json:"device_name"`
|
||||
DeviceType string `db:"device_type" json:"device_type"`
|
||||
}
|
||||
|
||||
// Get unlinked books for a device
|
||||
// Get unlinked books for a device (ignored rows stay hidden)
|
||||
func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetUnlinkedBooksByDevice, deviceID)
|
||||
if err != nil {
|
||||
@@ -9915,6 +10106,7 @@ func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
&i.DeviceName,
|
||||
&i.DeviceType,
|
||||
); err != nil {
|
||||
@@ -9942,6 +10134,7 @@ SELECT
|
||||
u.updated_at,
|
||||
u.timezone,
|
||||
u.default_audio_speed,
|
||||
u.keep_screen_awake,
|
||||
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
||||
FROM users u
|
||||
WHERE u.id = $1
|
||||
@@ -9960,6 +10153,7 @@ type GetUserRow struct {
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
||||
DeviceCount int64 `db:"device_count" json:"device_count"`
|
||||
}
|
||||
|
||||
@@ -9979,6 +10173,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
|
||||
&i.UpdatedAt,
|
||||
&i.Timezone,
|
||||
&i.DefaultAudioSpeed,
|
||||
&i.KeepScreenAwake,
|
||||
&i.DeviceCount,
|
||||
)
|
||||
return i, err
|
||||
@@ -10982,6 +11177,17 @@ func (q *Queries) HasRecentConflictResolution(ctx context.Context, arg HasRecent
|
||||
return exists, err
|
||||
}
|
||||
|
||||
const IgnoreUnlinkedBook = `-- name: IgnoreUnlinkedBook :exec
|
||||
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1
|
||||
`
|
||||
|
||||
// Soft-dismiss one row (ownership is checked by the caller against the
|
||||
// device's user_id before calling this).
|
||||
func (q *Queries) IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, IgnoreUnlinkedBook, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const IncrementSyncQueueAttempts = `-- name: IncrementSyncQueueAttempts :one
|
||||
UPDATE sync_queue
|
||||
SET
|
||||
@@ -11068,7 +11274,7 @@ SET
|
||||
resolved_at = NOW(),
|
||||
resolution_method = 'manual_link'
|
||||
WHERE id = $1
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
|
||||
`
|
||||
|
||||
type LinkUnlinkedBookParams struct {
|
||||
@@ -11095,6 +11301,7 @@ func (q *Queries) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookPara
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -11677,6 +11884,72 @@ func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaIt
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListIgnoredUnlinkedBooksForUser = `-- name: ListIgnoredUnlinkedBooksForUser :many
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
|
||||
ORDER BY ub.ignored_at DESC
|
||||
`
|
||||
|
||||
type ListIgnoredUnlinkedBooksForUserRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||
ContentID string `db:"content_id" json:"content_id"`
|
||||
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
||||
Title pgtype.Text `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
|
||||
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
||||
DeviceName string `db:"device_name" json:"device_name"`
|
||||
DeviceType string `db:"device_type" json:"device_type"`
|
||||
}
|
||||
|
||||
// Page list, ignored bucket: rows the user dismissed, kept for the
|
||||
// collapsible "Ignored" section so a misclick is recoverable.
|
||||
func (q *Queries) ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListIgnoredUnlinkedBooksForUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListIgnoredUnlinkedBooksForUserRow{}
|
||||
for rows.Next() {
|
||||
var i ListIgnoredUnlinkedBooksForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.DeviceID,
|
||||
&i.ContentID,
|
||||
&i.FilePath,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.ConfidenceScore,
|
||||
&i.Resolved,
|
||||
&i.MediaItemID,
|
||||
&i.ResolvedAt,
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
&i.DeviceName,
|
||||
&i.DeviceType,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListLibraries = `-- name: ListLibraries :many
|
||||
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description
|
||||
FROM libraries l
|
||||
@@ -13037,7 +13310,7 @@ func (q *Queries) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUI
|
||||
}
|
||||
|
||||
const ListUnresolvedUnlinkedBooks = `-- name: ListUnresolvedUnlinkedBooks :many
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE ub.resolved = false
|
||||
@@ -13064,6 +13337,7 @@ type ListUnresolvedUnlinkedBooksRow struct {
|
||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
||||
DeviceName string `db:"device_name" json:"device_name"`
|
||||
DeviceType string `db:"device_type" json:"device_type"`
|
||||
}
|
||||
@@ -13092,6 +13366,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
&i.DeviceName,
|
||||
&i.DeviceType,
|
||||
); err != nil {
|
||||
@@ -13106,10 +13381,10 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
|
||||
}
|
||||
|
||||
const ListUnresolvedUnlinkedBooksForUser = `-- name: ListUnresolvedUnlinkedBooksForUser :many
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
|
||||
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE d.user_id = $1 AND ub.resolved = false
|
||||
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
||||
ORDER BY ub.last_seen_at DESC
|
||||
`
|
||||
|
||||
@@ -13127,11 +13402,13 @@ type ListUnresolvedUnlinkedBooksForUserRow struct {
|
||||
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
|
||||
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
|
||||
DeviceName string `db:"device_name" json:"device_name"`
|
||||
DeviceType string `db:"device_type" json:"device_type"`
|
||||
}
|
||||
|
||||
// Page list: unresolved queue rows for the caller's own devices.
|
||||
// Page list: unresolved queue rows for the caller's own devices (ignored
|
||||
// rows are listed by ListIgnoredUnlinkedBooksForUser instead).
|
||||
func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID)
|
||||
if err != nil {
|
||||
@@ -13155,6 +13432,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
&i.DeviceName,
|
||||
&i.DeviceType,
|
||||
); err != nil {
|
||||
@@ -13811,7 +14089,7 @@ SET
|
||||
resolved_at = NOW(),
|
||||
resolution_method = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
|
||||
`
|
||||
|
||||
type ResolveUnlinkedBookParams struct {
|
||||
@@ -13838,6 +14116,7 @@ func (q *Queries) ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBo
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -14460,11 +14739,17 @@ SELECT
|
||||
mi.opf_uuid,
|
||||
mi.hash_confidence,
|
||||
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
|
||||
JOIN libraries l ON mi.library_id = l.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 reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
@@ -14730,6 +15015,7 @@ type SearchMediaItemsUnifiedRow struct {
|
||||
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
||||
LibraryName string `db:"library_name" json:"library_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) {
|
||||
@@ -14825,6 +15111,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
|
||||
&i.HashConfidence,
|
||||
&i.LibraryName,
|
||||
&i.LibraryTypeName,
|
||||
&i.ViewerPercentage,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -15243,6 +15530,16 @@ func (q *Queries) TombstoneSystemCollection(ctx context.Context, id pgtype.UUID)
|
||||
return err
|
||||
}
|
||||
|
||||
const UnignoreUnlinkedBook = `-- name: UnignoreUnlinkedBook :exec
|
||||
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1
|
||||
`
|
||||
|
||||
// Undo a soft-dismiss.
|
||||
func (q *Queries) UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, UnignoreUnlinkedBook, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const UpdateCollection = `-- name: UpdateCollection :one
|
||||
UPDATE collections
|
||||
SET
|
||||
@@ -17236,9 +17533,23 @@ func (q *Queries) UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUse
|
||||
return err
|
||||
}
|
||||
|
||||
const UpdateUserKeepScreenAwake = `-- name: UpdateUserKeepScreenAwake :exec
|
||||
UPDATE users SET keep_screen_awake = $2, updated_at = NOW() WHERE id = $1
|
||||
`
|
||||
|
||||
type UpdateUserKeepScreenAwakeParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error {
|
||||
_, err := q.db.Exec(ctx, UpdateUserKeepScreenAwake, arg.ID, arg.KeepScreenAwake)
|
||||
return err
|
||||
}
|
||||
|
||||
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
|
||||
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
|
||||
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed
|
||||
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed, keep_screen_awake
|
||||
`
|
||||
|
||||
type UpdateUserMaxDevicesParams struct {
|
||||
@@ -17263,6 +17574,7 @@ func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDev
|
||||
&i.Timezone,
|
||||
&i.UpdatedAt,
|
||||
&i.DefaultAudioSpeed,
|
||||
&i.KeepScreenAwake,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -17631,7 +17943,7 @@ SET file_path = EXCLUDED.file_path,
|
||||
author = EXCLUDED.author,
|
||||
confidence_score = EXCLUDED.confidence_score,
|
||||
last_seen_at = NOW()
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
|
||||
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
|
||||
`
|
||||
|
||||
type UpsertUnlinkedBookParams struct {
|
||||
@@ -17669,6 +17981,7 @@ func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBook
|
||||
&i.ResolutionMethod,
|
||||
&i.LastSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.IgnoredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -29,6 +29,7 @@ SELECT
|
||||
u.updated_at,
|
||||
u.timezone,
|
||||
u.default_audio_speed,
|
||||
u.keep_screen_awake,
|
||||
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
||||
FROM users u
|
||||
WHERE u.id = $1;
|
||||
@@ -515,6 +516,9 @@ UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
|
||||
-- name: UpdateUserDefaultAudioSpeed :exec
|
||||
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- name: UpdateUserKeepScreenAwake :exec
|
||||
UPDATE users SET keep_screen_awake = $2, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- Per-book playback speed preference (player plan §8).
|
||||
|
||||
-- name: GetAudioPlaybackSpeed :one
|
||||
@@ -730,11 +734,17 @@ SELECT
|
||||
mi.opf_uuid,
|
||||
mi.hash_confidence,
|
||||
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
|
||||
JOIN libraries l ON mi.library_id = l.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 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
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
@@ -3697,12 +3707,12 @@ INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, con
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *;
|
||||
|
||||
-- Get unlinked books for a device
|
||||
-- Get unlinked books for a device (ignored rows stay hidden)
|
||||
-- name: GetUnlinkedBooksByDevice :many
|
||||
SELECT ub.*, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE ub.device_id = $1 AND ub.resolved = false
|
||||
WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
||||
ORDER BY ub.last_seen_at DESC;
|
||||
|
||||
-- Get unlinked book by ContentId
|
||||
@@ -3720,11 +3730,11 @@ SET
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- Count unlinked books for a device
|
||||
-- Count unlinked books for a device (ignored rows stay hidden)
|
||||
-- name: CountUnlinkedBooks :one
|
||||
SELECT COUNT(*) as count
|
||||
FROM unlinked_books
|
||||
WHERE device_id = $1 AND resolved = false;
|
||||
WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL;
|
||||
|
||||
-- Link unlinked book to media item
|
||||
-- name: LinkUnlinkedBook :one
|
||||
@@ -3768,21 +3778,48 @@ SET file_path = EXCLUDED.file_path,
|
||||
last_seen_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- Banner count: unresolved queue rows across the caller's own devices.
|
||||
-- Banner count: unresolved, non-ignored queue rows across the caller's own
|
||||
-- devices. Ignored rows must not nag from the dashboard badge.
|
||||
-- name: CountUnresolvedUnlinkedBooksForUser :one
|
||||
SELECT COUNT(*) as count
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE d.user_id = $1 AND ub.resolved = false;
|
||||
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL;
|
||||
|
||||
-- Page list: unresolved queue rows for the caller's own devices.
|
||||
-- Page list: unresolved queue rows for the caller's own devices (ignored
|
||||
-- rows are listed by ListIgnoredUnlinkedBooksForUser instead).
|
||||
-- name: ListUnresolvedUnlinkedBooksForUser :many
|
||||
SELECT ub.*, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE d.user_id = $1 AND ub.resolved = false
|
||||
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
||||
ORDER BY ub.last_seen_at DESC;
|
||||
|
||||
-- Page list, ignored bucket: rows the user dismissed, kept for the
|
||||
-- collapsible "Ignored" section so a misclick is recoverable.
|
||||
-- name: ListIgnoredUnlinkedBooksForUser :many
|
||||
SELECT ub.*, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
|
||||
ORDER BY ub.ignored_at DESC;
|
||||
|
||||
-- Soft-dismiss one row (ownership is checked by the caller against the
|
||||
-- device's user_id before calling this).
|
||||
-- name: IgnoreUnlinkedBook :exec
|
||||
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1;
|
||||
|
||||
-- Undo a soft-dismiss.
|
||||
-- name: UnignoreUnlinkedBook :exec
|
||||
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1;
|
||||
|
||||
-- Fetch one row together with its device's owner for authorization.
|
||||
-- name: GetUnlinkedBookWithOwner :one
|
||||
SELECT ub.*, d.user_id, d.device_name, d.device_type
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE ub.id = $1;
|
||||
|
||||
-- ============================================
|
||||
-- CAROUSEL-STYLE DASHBOARD
|
||||
-- ============================================
|
||||
@@ -4040,32 +4077,126 @@ ORDER BY mi.created_at DESC;
|
||||
-- Series are grouped PER LIBRARY: the same series name in two libraries
|
||||
-- (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||
-- 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,
|
||||
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
|
||||
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)
|
||||
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 missing_scan_count = 0
|
||||
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
|
||||
-- Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
|
||||
-- transaction share a single NOW() timestamp, leaving many groups tied on
|
||||
-- MAX(created_at). Postgres does not guarantee tie order between
|
||||
-- executions, so page boundaries could repeat or drop series. The GROUP BY
|
||||
-- keys make the sort total and deterministic.
|
||||
ORDER BY MAX(created_at) DESC, 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
|
||||
)
|
||||
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');
|
||||
|
||||
-- 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
|
||||
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)
|
||||
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 missing_scan_count = 0
|
||||
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 (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[]))
|
||||
WHERE library_id = sqlc.narg('library_id')::uuid
|
||||
AND id != sqlc.narg('id')::uuid
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != '';
|
||||
AND file_path LIKE (sqlc.narg('dir_prefix')::text || '%');
|
||||
|
||||
-- name: GetSeriesCovers :many
|
||||
SELECT cover_image_path, library_id
|
||||
|
||||
@@ -147,6 +147,9 @@ var Schemas = map[string]Schema{
|
||||
"POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": {
|
||||
Response: json.RawMessage(`{"success": false, "message": ""}`),
|
||||
},
|
||||
"POST /api/libraries/:id/issues/:issueId/retry": {
|
||||
Response: json.RawMessage(`{"success": false, "message": ""}`),
|
||||
},
|
||||
"DELETE /api/libraries/:id/issues/:issueId": {
|
||||
Response: json.RawMessage(`{"success": false, "message": ""}`),
|
||||
},
|
||||
@@ -705,7 +708,7 @@ var Schemas = map[string]Schema{
|
||||
},
|
||||
"PUT /api/auth/profile": {
|
||||
// Shared with admin variant (PUT /auth/profile/:id).
|
||||
Request: handlers.AdminUpdateUserRequest{},
|
||||
Request: handlers.ProfileUpdateRequest{},
|
||||
Response: json.RawMessage(`{"message": ""}`),
|
||||
},
|
||||
"DELETE /api/auth/profile": {
|
||||
|
||||
+45
-32
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"math"
|
||||
"net/http"
|
||||
@@ -122,21 +123,11 @@ type UserProfile struct {
|
||||
Theme string `json:"theme,omitempty"`
|
||||
// User-level default audiobook playback speed (plan §8); 0 omits.
|
||||
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
|
||||
// Whether the web reader/player should hold a Screen Wake Lock.
|
||||
KeepScreenAwake bool `json:"keep_screen_awake"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||
// Per the SPEEDS list bounds; the profile select always sends it,
|
||||
// JSON clients omit it (0 = untouched).
|
||||
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
|
||||
}
|
||||
|
||||
type AdminUpdateUserRequest struct {
|
||||
type ProfileUpdateRequest struct {
|
||||
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||
@@ -144,7 +135,11 @@ type AdminUpdateUserRequest struct {
|
||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
||||
// Same semantics as UpdateProfileRequest.DefaultAudioSpeed.
|
||||
// "true"/"false" — the profile dropdown always sends it; JSON clients
|
||||
// that omit it decode as "" and leave the column untouched.
|
||||
KeepScreenAwake string `json:"keep_screen_awake,omitempty" form:"keep_screen_awake" validate:"omitempty,oneof=true false"`
|
||||
// Per the SPEEDS list bounds; the profile select always sends it,
|
||||
// JSON clients omit it (0 = untouched).
|
||||
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
|
||||
}
|
||||
|
||||
@@ -341,13 +336,9 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
||||
}
|
||||
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
html := fmt.Sprintf(`<div class="text-green-500">Registration successful! Redirecting...</div>
|
||||
<script>
|
||||
localStorage.setItem('token', '%s');
|
||||
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))
|
||||
// CSP: see the login handler — data attributes, not inline scripts.
|
||||
html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="/dashboard">Registration successful! Redirecting...</div>`,
|
||||
accessToken)
|
||||
return c.HTML(http.StatusCreated, html)
|
||||
}
|
||||
|
||||
@@ -374,6 +365,10 @@ window.location.href = '/dashboard';
|
||||
}
|
||||
|
||||
// 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 {
|
||||
login := c.FormValue("login")
|
||||
password := c.FormValue("password")
|
||||
@@ -430,7 +425,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
@@ -446,7 +441,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
@@ -487,17 +482,18 @@ func (h *AuthHandler) Login(c *echo.Context) error {
|
||||
}
|
||||
|
||||
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")
|
||||
if redirect == "" {
|
||||
if redirect == "" || !strings.HasPrefix(redirect, "/") {
|
||||
redirect = "/dashboard"
|
||||
}
|
||||
html := fmt.Sprintf(`<div class="text-green-500">Login successful! Redirecting...</div>
|
||||
<script>
|
||||
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)
|
||||
html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="%s">Login successful! Redirecting...</div>`,
|
||||
accessToken, html.EscapeString(redirect))
|
||||
return c.HTML(http.StatusOK, html)
|
||||
}
|
||||
|
||||
@@ -553,6 +549,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
|
||||
Role: user.Role,
|
||||
Theme: user.Theme.String,
|
||||
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
|
||||
KeepScreenAwake: user.KeepScreenAwake,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -581,7 +578,7 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
||||
targetUserUUID = currentUser.ID
|
||||
}
|
||||
|
||||
var req AdminUpdateUserRequest
|
||||
var req ProfileUpdateRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
@@ -717,6 +714,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep-screen-awake preference. The form dropdown always sends it
|
||||
// ("true"/"false"); JSON clients that omit it decode as "" and leave
|
||||
// the column untouched.
|
||||
if req.KeepScreenAwake != "" {
|
||||
if req.KeepScreenAwake != "true" && req.KeepScreenAwake != "false" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "keep_screen_awake must be 'true' or 'false'"})
|
||||
}
|
||||
err := h.db.UpdateUserKeepScreenAwake(c.Request().Context(), database.UpdateUserKeepScreenAwakeParams{
|
||||
ID: targetUserUUID,
|
||||
KeepScreenAwake: req.KeepScreenAwake == "true",
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// Browser path (htmx modal + profile page): refresh so every rendered
|
||||
// value (timezone select, table rows, header) reflects the save and the
|
||||
// modal closes. A bare JSON body swapped into #profile-result read as
|
||||
|
||||
+39
-15
@@ -13,7 +13,7 @@ import (
|
||||
// BookInfo is THE canonical wire+template card shape: every surface that
|
||||
// lists a book card as JSON (collections detail, series, preview,
|
||||
// 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.
|
||||
//
|
||||
// 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_archive — manga_type is unreliable across sources).
|
||||
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
|
||||
@@ -79,12 +88,14 @@ func MediaBadgeKind(formatGroup, libraryTypeName string) string {
|
||||
// adapters below normalize the sqlc row shapes into cardSource.
|
||||
func buildBookCard(src cardSource) BookInfo {
|
||||
return BookInfo{
|
||||
MediaItemID: src.ID.String(),
|
||||
Title: src.Title,
|
||||
Author: src.Author,
|
||||
CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
|
||||
FormatGroup: src.FormatGroup,
|
||||
Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
|
||||
MediaItemID: src.ID.String(),
|
||||
Title: src.Title,
|
||||
Author: src.Author,
|
||||
CoverImagePath: utils.ResolveMediaURL(src.LibraryID, src.CoverPath),
|
||||
FormatGroup: src.FormatGroup,
|
||||
Badge: MediaBadgeKind(src.FormatGroup, src.LibraryType),
|
||||
ViewerPercentage: src.ViewerProgress,
|
||||
LibraryName: src.LibraryName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +113,10 @@ type cardSource struct {
|
||||
CoverPath pgtype.Text
|
||||
FormatGroup 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
|
||||
@@ -167,18 +182,27 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
|
||||
|
||||
// BookCardsFromUnified builds cards from search-unified rows. Those
|
||||
// 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 {
|
||||
cards := make([]BookInfo, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var viewer *float64
|
||||
if r.ViewerPercentage.Valid {
|
||||
pct := r.ViewerPercentage.Float64
|
||||
viewer = &pct
|
||||
}
|
||||
cards = append(cards, buildBookCard(cardSource{
|
||||
ID: uuid.UUID(r.ID.Bytes),
|
||||
Title: r.Title,
|
||||
Author: utils.PgTextString(r.Author),
|
||||
LibraryID: r.LibraryID,
|
||||
CoverPath: r.CoverImagePath,
|
||||
FormatGroup: r.FormatGroup,
|
||||
LibraryType: r.LibraryTypeName,
|
||||
ID: uuid.UUID(r.ID.Bytes),
|
||||
Title: r.Title,
|
||||
Author: utils.PgTextString(r.Author),
|
||||
LibraryID: r.LibraryID,
|
||||
CoverPath: r.CoverImagePath,
|
||||
FormatGroup: r.FormatGroup,
|
||||
LibraryType: r.LibraryTypeName,
|
||||
ViewerProgress: viewer,
|
||||
LibraryName: r.LibraryName,
|
||||
}))
|
||||
}
|
||||
return cards
|
||||
|
||||
@@ -496,7 +496,8 @@ func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -499,7 +499,10 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
|
||||
|
||||
// Handle HTMX requests - return HTML with page reload script
|
||||
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.JSON(http.StatusOK, map[string]interface{}{
|
||||
|
||||
@@ -1801,6 +1801,57 @@ func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
|
||||
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)
|
||||
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
@@ -3,6 +3,8 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -218,6 +220,77 @@ func (h *ProcessingIssuesHandler) gateIssue(c *echo.Context, issueID uuid.UUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetryProcessingIssue re-runs the work that produced an open issue
|
||||
// through the individual scanner — NOT a library-wide rescan:
|
||||
//
|
||||
// - item-backed issues rescan their media item (the book-detail
|
||||
// RescanMediaItem mechanism: self-resolving folders, multi-file
|
||||
// audiobook routing, write-after-success guards);
|
||||
// - row-less import_failed issues re-import the single file through
|
||||
// the watcher's standalone processMediaFile path.
|
||||
//
|
||||
// Success closes the issue (explicitly for the item shape; the create
|
||||
// path's self-heal plus the explicit path resolve below for row-less).
|
||||
// Failure returns the REAL error so the UI can say why it still fails.
|
||||
func (h *ProcessingIssuesHandler) RetryProcessingIssue(c *echo.Context) error {
|
||||
issueID, err := uuid.Parse(c.Param("issueId"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"})
|
||||
}
|
||||
|
||||
issue, err := h.db.GetProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Issue not found"})
|
||||
}
|
||||
if !issue.LibraryID.Valid {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has no library attribution"})
|
||||
}
|
||||
if err := h.gateIssue(c, issueID); err != nil {
|
||||
return err
|
||||
}
|
||||
if issue.Resolved.Valid && issue.Resolved.Bool {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue is already resolved"})
|
||||
}
|
||||
|
||||
scanner := services.NewMediaScanner(h.db)
|
||||
defer scanner.Close()
|
||||
|
||||
if issue.MediaItemID.Valid {
|
||||
if err := scanner.RetryMediaItemIssue(c.Request().Context(), issue.MediaItemID); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": "Item rescanned; issue resolved",
|
||||
})
|
||||
}
|
||||
|
||||
// Row-less import failure: the file never became a row.
|
||||
if !issue.FilePath.Valid || issue.FilePath.String == "" {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "Issue has neither a media item nor a file path"})
|
||||
}
|
||||
relativePath := issue.FilePath.String
|
||||
if err := scanner.RetryFailedImport(c.Request().Context(), issue.LibraryID, relativePath); err != nil {
|
||||
if errors.Is(err, services.ErrRetryFileMissing) {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
// The import chain's self-heal closes path-keyed issues on create;
|
||||
// an already-imported duplicate (a previous partial retry) skips
|
||||
// that path, so close explicitly here too. Idempotent.
|
||||
if _, err := h.db.ResolveOpenProcessingIssuesForPath(c.Request().Context(), database.ResolveOpenProcessingIssuesForPathParams{
|
||||
LibraryID: issue.LibraryID,
|
||||
FilePath: pgtype.Text{String: relativePath, Valid: true},
|
||||
}); err != nil {
|
||||
fmt.Printf("Warning: retry imported %s but failed to close its issues: %v\n", relativePath, err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": "File imported; issue resolved",
|
||||
})
|
||||
}
|
||||
|
||||
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
|
||||
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
|
||||
return h.db.GetProcessingIssueStats(ctx, libraryID)
|
||||
|
||||
@@ -27,7 +27,12 @@ func (h *Handler) StartBackgroundTasks() {
|
||||
type ScanLibraryRequest struct {
|
||||
FolderPaths []string `json:"folder_paths,omitempty"`
|
||||
LibraryID string `json:"library_id,omitempty"`
|
||||
Force bool `json:"force,omitempty"`
|
||||
// FolderPath (singular) scopes a library scan to ONE of the library's
|
||||
// registered folders (the LibraryPanel per-folder Scan buttons). It
|
||||
// must be a registered folder — validated below, never trusted. The
|
||||
// raw /api/scanner/scan route ignores it (no library attribution).
|
||||
FolderPath string `json:"folder_path,omitempty"`
|
||||
Force bool `json:"force,omitempty"`
|
||||
}
|
||||
|
||||
// ScanLibrary handles POST /api/scanner/scan (now runs in background)
|
||||
@@ -68,9 +73,28 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"})
|
||||
}
|
||||
|
||||
// Extract folder paths
|
||||
for _, folder := range libraryFolders {
|
||||
folderPaths = append(folderPaths, folder.FolderPath)
|
||||
if req.FolderPath != "" {
|
||||
// Per-folder scope (LibraryPanel per-folder Scan buttons):
|
||||
// scan exactly one registered folder's subtree. The archive
|
||||
// reconciliation pass skips partially-covered libraries
|
||||
// (processScanJob → ScanFolders), so the other folders'
|
||||
// items cannot be marked missing by this run.
|
||||
registered := false
|
||||
for _, folder := range libraryFolders {
|
||||
if folder.FolderPath == req.FolderPath {
|
||||
registered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !registered {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "folder_path is not one of this library's registered folders"})
|
||||
}
|
||||
folderPaths = []string{req.FolderPath}
|
||||
} else {
|
||||
// Extract folder paths
|
||||
for _, folder := range libraryFolders {
|
||||
folderPaths = append(folderPaths, folder.FolderPath)
|
||||
}
|
||||
}
|
||||
|
||||
if len(folderPaths) == 0 {
|
||||
|
||||
@@ -64,7 +64,20 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
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 {
|
||||
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"`
|
||||
CoverPaths []string `json:"cover_paths"`
|
||||
LastEntryAt string `json:"last_entry_at"`
|
||||
ReadCount int64 `json:"read_count"`
|
||||
LastReadAt string `json:"last_read_at"`
|
||||
}
|
||||
|
||||
response := make([]SeriesResponse, 0, len(seriesList))
|
||||
@@ -87,6 +102,8 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: s.CoverPaths,
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
// 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(`
|
||||
<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>
|
||||
</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);">
|
||||
<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 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
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
</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
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
@@ -9,7 +11,14 @@ import (
|
||||
// the rendered component by security_headers_test.go.
|
||||
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 {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
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
|
||||
// ONLY inline script in the app, and its byte-stable content is
|
||||
// hashed into script-src (sha256 of the bytes between
|
||||
// <script> and </script> as rendered by
|
||||
// hashed into script-src (sha256 of the bytes between the
|
||||
// script tags as rendered by
|
||||
// templates.ThemeFlashGuard()). Editing the guard requires
|
||||
// recomputing the hash — enforced by
|
||||
// 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("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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"bookhoard/internal/middleware"
|
||||
"bookhoard/templates"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+221
-3
@@ -149,6 +149,36 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
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
|
||||
totalPages := 1
|
||||
|
||||
@@ -162,7 +192,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
} else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] {
|
||||
errorMsg = "Library not accessible"
|
||||
} 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 {
|
||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||
errorMsg = "Error loading series"
|
||||
@@ -183,6 +213,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: covers,
|
||||
ReadCount: s.ReadCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -194,7 +225,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -767,6 +798,8 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// resolution rules as the dashboard page (family-lens safe).
|
||||
// Unlinked-books page: the identification queue for device files the
|
||||
// sync could not match (personal data — the caller's own devices only).
|
||||
// Unlinked-book queue page: unresolved rows plus the ignored bucket
|
||||
// (soft-dismissed rows, kept for the collapsible recovery section).
|
||||
frontendProtected.GET("/unlinked-books", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
@@ -781,6 +814,11 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr)
|
||||
rows = []database.ListUnresolvedUnlinkedBooksForUserRow{}
|
||||
}
|
||||
ignoredRows, ignoredErr := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if ignoredErr != nil {
|
||||
log.Printf("ListIgnoredUnlinkedBooksForUser failed: %v", ignoredErr)
|
||||
ignoredRows = []database.ListIgnoredUnlinkedBooksForUserRow{}
|
||||
}
|
||||
|
||||
books := make([]templates.UnlinkedBookData, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
@@ -796,14 +834,133 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
ConfidenceScore: r.ConfidenceScore.Float64,
|
||||
})
|
||||
}
|
||||
ignoredBooks := make([]templates.UnlinkedBookData, 0, len(ignoredRows))
|
||||
for _, r := range ignoredRows {
|
||||
ignoredBooks = append(ignoredBooks, templates.UnlinkedBookData{
|
||||
ProgressID: uuid.UUID(r.ID.Bytes).String(),
|
||||
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
|
||||
DeviceType: r.DeviceType,
|
||||
DeviceName: r.DeviceName,
|
||||
FilePath: utils.PgTextString(r.FilePath),
|
||||
SHA256: r.ContentID,
|
||||
TitleFromDevice: utils.PgTextString(r.Title),
|
||||
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, user.Timezone),
|
||||
ConfidenceScore: r.ConfidenceScore.Float64,
|
||||
})
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if renderErr := templates.UnlinkedBooks(user, books).Render(c.Request().Context(), &buf); renderErr != nil {
|
||||
if renderErr := templates.UnlinkedBooks(user, books, ignoredBooks).Render(c.Request().Context(), &buf); renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Unlinked-book dismiss/delete actions (htmx: the buttons post/delete
|
||||
// and swap in the re-rendered section). Authorization is row → device
|
||||
// → requesting user (admin may act on anyone's rows); a non-htmx hit
|
||||
// just bounces back to the page. These are page-scoped SSR routes, so
|
||||
// they are intentionally NOT under /api and not in the OpenAPI docs.
|
||||
unlinkedSectionHTML := func(c *echo.Context, dbUser database.Users) error {
|
||||
userUUID, err := uuid.Parse(dbUser.ID.String())
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
|
||||
}
|
||||
rows, err := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
|
||||
}
|
||||
ignoredRows, err := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
|
||||
}
|
||||
books := make([]templates.UnlinkedBookData, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
books = append(books, templates.UnlinkedBookData{
|
||||
ProgressID: uuid.UUID(r.ID.Bytes).String(),
|
||||
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
|
||||
DeviceType: r.DeviceType,
|
||||
DeviceName: r.DeviceName,
|
||||
FilePath: utils.PgTextString(r.FilePath),
|
||||
SHA256: r.ContentID,
|
||||
TitleFromDevice: utils.PgTextString(r.Title),
|
||||
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
|
||||
ConfidenceScore: r.ConfidenceScore.Float64,
|
||||
})
|
||||
}
|
||||
ignoredBooks := make([]templates.UnlinkedBookData, 0, len(ignoredRows))
|
||||
for _, r := range ignoredRows {
|
||||
ignoredBooks = append(ignoredBooks, templates.UnlinkedBookData{
|
||||
ProgressID: uuid.UUID(r.ID.Bytes).String(),
|
||||
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
|
||||
DeviceType: r.DeviceType,
|
||||
DeviceName: r.DeviceName,
|
||||
FilePath: utils.PgTextString(r.FilePath),
|
||||
SHA256: r.ContentID,
|
||||
TitleFromDevice: utils.PgTextString(r.Title),
|
||||
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
|
||||
ConfidenceScore: r.ConfidenceScore.Float64,
|
||||
})
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if renderErr := templates.UnlinkedBooksSection(books, ignoredBooks).Render(c.Request().Context(), &buf); renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}
|
||||
// authorizeUnlinkedAction resolves the :id row with its device owner
|
||||
// and gates the action: nil row + status/message on failure.
|
||||
authorizeUnlinkedAction := func(c *echo.Context, dbUser database.Users) (*database.GetUnlinkedBookWithOwnerRow, int, string) {
|
||||
rowID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return nil, http.StatusBadRequest, "<div>Invalid entry id</div>"
|
||||
}
|
||||
row, err := cfg.Queries.GetUnlinkedBookWithOwner(c.Request().Context(), pgtype.UUID{Bytes: rowID, Valid: true})
|
||||
if err != nil {
|
||||
return nil, http.StatusNotFound, "<div>Entry not found</div>"
|
||||
}
|
||||
ownerID := uuid.UUID(row.UserID.Bytes).String()
|
||||
if ownerID != dbUser.ID.String() && dbUser.Role != "admin" {
|
||||
return nil, http.StatusForbidden, "<div>Not allowed</div>"
|
||||
}
|
||||
return &row, 0, ""
|
||||
}
|
||||
for _, action := range []struct {
|
||||
method string
|
||||
path string
|
||||
run func(ctx context.Context, rowID pgtype.UUID) error
|
||||
}{
|
||||
{"POST", "/unlinked-books/:id/ignore", func(ctx context.Context, rowID pgtype.UUID) error {
|
||||
return cfg.Queries.IgnoreUnlinkedBook(ctx, rowID)
|
||||
}},
|
||||
{"POST", "/unlinked-books/:id/unignore", func(ctx context.Context, rowID pgtype.UUID) error {
|
||||
return cfg.Queries.UnignoreUnlinkedBook(ctx, rowID)
|
||||
}},
|
||||
{"DELETE", "/unlinked-books/:id", func(ctx context.Context, rowID pgtype.UUID) error {
|
||||
return cfg.Queries.DeleteUnlinkedBook(ctx, rowID)
|
||||
}},
|
||||
} {
|
||||
method, path, run := action.method, action.path, action.run
|
||||
frontendProtected.Add(method, path, func(c *echo.Context) error {
|
||||
dbUser, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
|
||||
}
|
||||
row, status, msg := authorizeUnlinkedAction(c, dbUser)
|
||||
if row == nil {
|
||||
return c.HTML(status, msg)
|
||||
}
|
||||
if err := run(c.Request().Context(), row.ID); err != nil {
|
||||
log.Printf("unlinked-book action %s %s failed: %v", method, path, err)
|
||||
return c.HTML(http.StatusInternalServerError, "<div>Action failed</div>")
|
||||
}
|
||||
if c.Request().Header.Get("HX-Request") != "true" {
|
||||
return c.Redirect(http.StatusSeeOther, "/unlinked-books")
|
||||
}
|
||||
return unlinkedSectionHTML(c, dbUser)
|
||||
})
|
||||
}
|
||||
|
||||
// Unlinked-book match results partial: the match cascade for one device
|
||||
// file, rendered server-side and scoped to the requester's visible
|
||||
// libraries (absent-from-results) — rendering-ownership: templ renders,
|
||||
@@ -1287,6 +1444,67 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}))
|
||||
|
||||
// Content partial for the retry queue: after the client drains its
|
||||
// sequential retry queue it swaps this in over #issues-content so
|
||||
// resolved issues drop off server-side (unlinked_books matches
|
||||
// partial precedent). Same family-lens gate as the page.
|
||||
frontendProtected.GET("/admin/libraries/:id/issues/content", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
libraryID := c.Param("id")
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Invalid library ID", "invalid_id")
|
||||
}
|
||||
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
|
||||
if visErr != nil {
|
||||
return renderErrorPage(c, "Error checking library access", "visibility_error")
|
||||
}
|
||||
if !visibleSet[libraryID] {
|
||||
return renderErrorPage(c, "Library not accessible", "library_not_accessible")
|
||||
}
|
||||
stats, err := cfg.Queries.GetProcessingIssueStats(c.Request().Context(), uuidToPGType(libUUID))
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading stats", "stats_load_error")
|
||||
}
|
||||
dbIssues, err := cfg.Queries.ListProcessingIssuesByLibrary(c.Request().Context(), uuidToPGType(libUUID))
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading issues", "issues_load_error")
|
||||
}
|
||||
issues := make([]templates.ProcessingIssueData, len(dbIssues))
|
||||
for i, issue := range dbIssues {
|
||||
issueUUID, _ := uuid.FromBytes(issue.ID.Bytes[0:16])
|
||||
itemID := ""
|
||||
if issue.MediaItemID.Valid {
|
||||
itemID = uuid.UUID(issue.MediaItemID.Bytes).String()
|
||||
}
|
||||
title := issue.Title
|
||||
if title == "" {
|
||||
title = issue.FilePath
|
||||
}
|
||||
issues[i] = templates.ProcessingIssueData{
|
||||
ID: issueUUID.String(),
|
||||
MediaItemID: itemID,
|
||||
Title: title,
|
||||
FilePath: issue.FilePath,
|
||||
FormatGroup: issue.FormatGroup,
|
||||
LibraryTypeName: issue.LibraryTypeName,
|
||||
IssueType: issue.IssueType,
|
||||
IssueDescription: issue.IssueDescription,
|
||||
Severity: issue.Severity,
|
||||
CreatedAt: issue.CreatedAt.Time,
|
||||
}
|
||||
}
|
||||
statsData := templates.IssueStats{
|
||||
ErrorCount: stats.ErrorCount,
|
||||
WarningCount: stats.WarningCount,
|
||||
InfoCount: stats.InfoCount,
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := templates.ProcessingIssuesContent(libraryID, issues, statsData).Render(c.Request().Context(), &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}))
|
||||
|
||||
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
||||
// backfill or rescan, resolved by keeping all copies or merging into one.
|
||||
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
|
||||
@@ -67,6 +67,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
||||
LastName: utils.PgTextString(userDB.LastName),
|
||||
CreatedAt: userDB.CreatedAt.Time,
|
||||
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
||||
KeepScreenAwake: userDB.KeepScreenAwake,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ func registerLibraryRoutes(cfg *Config) {
|
||||
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
|
||||
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
|
||||
adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue)
|
||||
adminLibrary.POST("/:id/issues/:issueId/retry", cfg.ProcessingIssuesHandler.RetryProcessingIssue)
|
||||
adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
|
||||
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
|
||||
libraryID := c.Param("id")
|
||||
|
||||
@@ -74,6 +74,7 @@ func registerMediaRoutes(cfg *Config) {
|
||||
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
||||
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
||||
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.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems)
|
||||
admin.POST("/media-items/:id/unarchive", cfg.MediaHandler.UnarchiveMediaItem)
|
||||
|
||||
@@ -100,6 +100,7 @@ func registerReaderRoutes(cfg *Config) {
|
||||
TotalCharacters: mediaItem.TotalCharacters.Int64,
|
||||
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
|
||||
KeepScreenAwake: user.KeepScreenAwake,
|
||||
}
|
||||
// Render template
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -11,9 +11,13 @@ import (
|
||||
"bookhoard/templates"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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")
|
||||
|
||||
// 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)
|
||||
// Render using BooksGrid template (or BookPickerGrid for collection picker)
|
||||
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)
|
||||
} 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)
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -831,18 +831,70 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
// retention window (ARCHIVE_RETENTION_DAYS; 0 = manual purge only).
|
||||
// Every branch logs - the previous hard-delete cleanup failed silently
|
||||
// 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 {
|
||||
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 {
|
||||
fmt.Printf("[ARCHIVE] Scan cancelled, stopping archive pass\n")
|
||||
return ctx.Err()
|
||||
}
|
||||
libraryID := scope.libraryID
|
||||
|
||||
lib, err := s.db.GetLibraryByFolder(ctx, folder)
|
||||
if err != nil {
|
||||
fmt.Printf("[ARCHIVE] Warning: no library found for folder %s, skipping archive pass: %v\n", folder, err)
|
||||
// Partial-scope guard (per-folder scans, 2026-10): the
|
||||
// reconciliation compares EVERY library item against this scan's
|
||||
// disk walk — a scan that covered only some of the library's
|
||||
// registered folders (the LibraryPanel per-folder Scan buttons)
|
||||
// saw nothing from the others and would mark their items missing
|
||||
// → archived. Reconcile only when the scan covered all of them.
|
||||
registeredFolders, regErr := s.db.GetLibraryFolders(ctx, libraryID)
|
||||
if regErr != nil {
|
||||
fmt.Printf("[ARCHIVE] Warning: failed to list folders for library, skipping archive pass: %v\n", regErr)
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
libraryID := lib.LibraryID
|
||||
|
||||
dbItems, err := s.db.ListMediaItemsByLibraryIncludingArchived(ctx, libraryID)
|
||||
if err != nil {
|
||||
@@ -850,26 +902,39 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
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)
|
||||
if err := filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
walkFailed := false
|
||||
for _, folder := range scope.folders {
|
||||
if err := filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if !d.IsDir() && s.isScannableFile(path) {
|
||||
scannedPaths[s.getRelativePath(path)] = true
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
fmt.Printf("[ARCHIVE] Warning: failed to walk directory %s, skipping archive pass: %v\n", folder, err)
|
||||
walkFailed = true
|
||||
break
|
||||
}
|
||||
if !d.IsDir() && s.isScannableFile(path) {
|
||||
scannedPaths[s.getRelativePath(path)] = true
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
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
|
||||
}
|
||||
if walkFailed {
|
||||
continue // Skip this library to avoid false archivals
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if strings.HasPrefix(root, folder) {
|
||||
scannedPaths[s.getRelativePath(root)] = true
|
||||
for _, folder := range scope.folders {
|
||||
if folderContains(folder, root) {
|
||||
scannedPaths[s.getRelativePath(root)] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,7 +992,7 @@ func (s *MediaScanner) isScannableFile(path string) bool {
|
||||
// Find which folder this file belongs to
|
||||
var folder string
|
||||
for _, f := range s.folders {
|
||||
if strings.HasPrefix(path, f) {
|
||||
if folderContains(f, path) {
|
||||
folder = f
|
||||
break
|
||||
}
|
||||
@@ -1090,7 +1155,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
folderLibrary := s.folderLibrary
|
||||
s.scanStateMu.RUnlock()
|
||||
for _, folder := range s.folders {
|
||||
if !strings.HasPrefix(path, folder) {
|
||||
if !folderContains(folder, path) {
|
||||
continue
|
||||
}
|
||||
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
|
||||
var rootFolder string
|
||||
for _, folder := range s.folders {
|
||||
if strings.HasPrefix(path, folder) {
|
||||
if folderContains(folder, path) {
|
||||
rootFolder = folder
|
||||
break
|
||||
}
|
||||
@@ -1577,6 +1642,21 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
|
||||
}
|
||||
if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
|
||||
fmt.Printf("Warning: failed to update media item: %v\n", err)
|
||||
// Ledger (Phase 11): the item exists — record item-backed so
|
||||
// the Processing Issues page flags the degraded metadata and
|
||||
// offers a retry, instead of failing silently on every scan
|
||||
// (the 2026-10 Crusader genre-22001 loop did exactly that).
|
||||
// Same type the sidecar-change path uses; resolveIssuesForItem
|
||||
// clears it when an update finally succeeds.
|
||||
if logErr := s.LogProcessingIssue(ctx,
|
||||
uuid.UUID(existingItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
|
||||
"metadata_extraction_failed",
|
||||
fmt.Sprintf("Metadata update failed; row left unchanged: %v", err),
|
||||
"warning"); logErr != nil {
|
||||
fmt.Printf("Warning: failed to record update issue for %s: %v\n", path, logErr)
|
||||
}
|
||||
} else {
|
||||
s.resolveIssuesForItem(ctx, existingItem.ID)
|
||||
}
|
||||
// The bytes changed, so any stored hash is stale.
|
||||
s.recomputeHashInfo(ctx, entry.ID, libraryID, path)
|
||||
@@ -1643,6 +1723,18 @@ func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *s
|
||||
}
|
||||
if err := s.updateMediaItemWithMetadata(ctx, existingItem, path, metadata); err != nil {
|
||||
fmt.Printf("Warning: failed to update media item after sidecar change %s: %v\n", path, err)
|
||||
// Ledger: same item-backed record as the size-change path —
|
||||
// a failed re-extraction must be visible and retryable, not a
|
||||
// scan-log whisper.
|
||||
if logErr := s.LogProcessingIssue(ctx,
|
||||
uuid.UUID(existingItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
|
||||
"metadata_extraction_failed",
|
||||
fmt.Sprintf("Metadata update after sidecar change failed; row left unchanged: %v", err),
|
||||
"warning"); logErr != nil {
|
||||
fmt.Printf("Warning: failed to record sidecar-update issue for %s: %v\n", path, logErr)
|
||||
}
|
||||
} else {
|
||||
s.resolveIssuesForItem(ctx, existingItem.ID)
|
||||
}
|
||||
// Playlist sync on the change path (plan Correction 2): a
|
||||
// "(Reading Order)" entry added to an EXISTING book's sidecar
|
||||
@@ -1719,23 +1811,24 @@ func applyTitleDerivedSeries(metadata *MediaMetadata) {
|
||||
|
||||
// extractCalibreSidecar checks for and parses a Calibre metadata.opf sidecar file
|
||||
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)
|
||||
opfPath := filepath.Join(dir, "metadata.opf")
|
||||
|
||||
// Check if sidecar exists
|
||||
if _, err := os.Stat(opfPath); os.IsNotExist(err) {
|
||||
return nil // No sidecar, not an error
|
||||
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
for _, name := range []string{base + ".opf", "metadata.opf"} {
|
||||
opfPath := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(opfPath); err != nil {
|
||||
continue
|
||||
}
|
||||
// Parse sidecar
|
||||
metadata, err := s.parseCalibreMetadataOPF(opfPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to parse Calibre metadata.opf: %v\n", err)
|
||||
continue // fall back to the next candidate / embedded
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
// Parse sidecar
|
||||
metadata, err := s.parseCalibreMetadataOPF(opfPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to parse Calibre metadata.opf: %v\n", err)
|
||||
return nil // Parsing failed, fall back to embedded
|
||||
}
|
||||
|
||||
return metadata
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyEmbeddedCoverFallback extracts a cover from the media file itself when
|
||||
@@ -3997,7 +4090,7 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
|
||||
// could clear series/author that a sidecar didn't carry (Libation
|
||||
// writes "series": null while the folder layout knows the series).
|
||||
for _, folder := range s.folders {
|
||||
if strings.HasPrefix(path, folder) {
|
||||
if folderContains(folder, path) {
|
||||
if folderMetadata := s.extractFolderStructureMetadata(path, folder); folderMetadata != nil {
|
||||
if metadata.Title == "" && folderMetadata.Title != "" {
|
||||
metadata.Title = folderMetadata.Title
|
||||
@@ -5006,13 +5099,27 @@ func (s *MediaScanner) extractHashInfo(filePath string) (*HashInfo, *FormatInfo,
|
||||
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 {
|
||||
// Get the base folder paths from scanner
|
||||
for _, baseFolder := range s.folders {
|
||||
// Check if path is within this base folder
|
||||
if relPath, ok := strings.CutPrefix(absolutePath, baseFolder); ok {
|
||||
return strings.TrimPrefix(relPath, "/")
|
||||
// Check if path is within this base folder — boundary-aware, so a
|
||||
// sibling whose name merely extends ours can never be cut.
|
||||
if !folderContains(baseFolder, absolutePath) {
|
||||
continue
|
||||
}
|
||||
return strings.TrimPrefix(absolutePath[len(filepath.Clean(baseFolder)):], "/")
|
||||
}
|
||||
// Fallback: if no match, return as-is (shouldn't happen)
|
||||
return absolutePath
|
||||
|
||||
@@ -30,6 +30,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/utils"
|
||||
)
|
||||
|
||||
// AudioChapter is the uniform chapter entry for every audiobook file model.
|
||||
@@ -394,7 +396,8 @@ func marshalAudioMetadata(payload *AudioMetadataPayload) []byte {
|
||||
// ----:com.apple.iTunes:SUBTITLE → joined into the title
|
||||
// ("Title: Subtitle"), mirroring the ABS sidecar path
|
||||
// ----:com.apple.iTunes:ASIN / ABRIDGED → asin / abridged flag
|
||||
// date → publish date; genre → genre
|
||||
// date → publish date; genre → smart-genre (shelf-or-empty column,
|
||||
// split candidates to tags — see pickSmartGenre)
|
||||
//
|
||||
// Abridged is the one field where an explicit sidecar false must not be
|
||||
// overridden by a tag — the payload pointer marks sidecar presence.
|
||||
@@ -472,7 +475,21 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
|
||||
}
|
||||
}
|
||||
if metadata.Genre == "" {
|
||||
metadata.Genre = report.tag("genre")
|
||||
// Same doctrine as the ABS sidecar path (ab2a5568): the raw file
|
||||
// atom is a comma-joined category blob ("Politicians, Presidents &
|
||||
// Heads of State, United States, ..." — Audible ladders, often past
|
||||
// 100 chars). Shelf-or-empty wins the genre column; every split
|
||||
// candidate lands in tags. No shelf match → the column stays empty
|
||||
// and the blob is tags-only, never a VARCHAR-overflowing INSERT.
|
||||
if genreTag := report.tag("genre"); genreTag != "" {
|
||||
genre, all := pickSmartGenre([]string{genreTag})
|
||||
if genre != "" {
|
||||
metadata.Genre = genre
|
||||
}
|
||||
if len(all) > 0 {
|
||||
metadata.Tags = utils.NormalizeTags(append(metadata.Tags, all...))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package services
|
||||
// and tag-based track ordering.
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -178,8 +179,14 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
|
||||
if meta.PublishDate.Year() != 2020 {
|
||||
t.Fatalf("publish date = %v", meta.PublishDate)
|
||||
}
|
||||
if meta.Genre != "Audiobook" {
|
||||
t.Fatalf("genre = %q", meta.Genre)
|
||||
// Smart-genre routing (2026-10 Crusader fix): "Audiobook" is a
|
||||
// DEMOTED genre (genreDemoted) — it is tags-only and never wins
|
||||
// the column, mirroring the sidecar path's pickSmartGenre.
|
||||
if meta.Genre != "" {
|
||||
t.Fatalf("demoted genre must not win the column, got %q", meta.Genre)
|
||||
}
|
||||
if !slices.Contains(meta.Tags, "Audiobook") {
|
||||
t.Fatalf("demoted genre must stay in tags, got %v", meta.Tags)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -214,6 +221,46 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
|
||||
t.Fatal("nil report must change nothing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("audible genre blob splits into tags, column stays empty when no shelf matches", func(t *testing.T) {
|
||||
// The 2026-10 Crusader regression: Libation writes the Audible
|
||||
// category ladder as one comma-joined atom — 111 chars here — and
|
||||
// the fallback used to capture it VERBATIM into media_items.genre,
|
||||
// failing the import/update with SQLSTATE 22001 (VARCHAR(100)).
|
||||
// The genre column takes a known shelf or stays empty; the split
|
||||
// candidates are tags-only, exactly like the sidecar path.
|
||||
crusader := "Politicians, Presidents & Heads of State, United States, Russia, Communism & Socialism, International Relations"
|
||||
if len(crusader) <= 100 {
|
||||
t.Fatalf("fixture must exceed the old VARCHAR(100) limit, len=%d", len(crusader))
|
||||
}
|
||||
meta := &MediaMetadata{}
|
||||
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{"genre": crusader}))
|
||||
if len(meta.Genre) > 100 {
|
||||
t.Fatalf("genre column value must never exceed 100 chars, got %d: %q", len(meta.Genre), meta.Genre)
|
||||
}
|
||||
if meta.Genre != "" {
|
||||
t.Fatalf("no candidate is a known shelf — column must stay empty, got %q", meta.Genre)
|
||||
}
|
||||
// NormalizeTags titlecases: "of" → "Of" is the shipped display form.
|
||||
for _, want := range []string{"Politicians", "Presidents & Heads Of State", "United States", "Russia", "Communism & Socialism", "International Relations"} {
|
||||
if !slices.Contains(meta.Tags, want) {
|
||||
t.Fatalf("tags must contain split candidate %q, got %v", want, meta.Tags)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("known shelf wins the column from a genre blob", func(t *testing.T) {
|
||||
meta := &MediaMetadata{}
|
||||
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
|
||||
"genre": "Epic, Action & Adventure, Military, Sword & Sorcery",
|
||||
}))
|
||||
if meta.Genre == "" {
|
||||
t.Fatalf("expected a lexicon shelf from the blob, tags=%v", meta.Tags)
|
||||
}
|
||||
if !slices.Contains(meta.Tags, "Epic") || !slices.Contains(meta.Tags, "Military") {
|
||||
t.Fatalf("split candidates must land in tags, got %v", meta.Tags)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseTagOrdinal(t *testing.T) {
|
||||
|
||||
@@ -178,7 +178,7 @@ func (s *MediaScanner) maybeProcessAudioBook(ctx context.Context, path string) (
|
||||
// so any scannable audio file belongs to one.
|
||||
rootFolder := ""
|
||||
for _, folder := range s.folders {
|
||||
if strings.HasPrefix(path, folder) {
|
||||
if folderContains(folder, path) {
|
||||
rootFolder = folder
|
||||
break
|
||||
}
|
||||
@@ -731,7 +731,7 @@ func (s *MediaScanner) rescanMultiFileAudioBook(ctx context.Context, item databa
|
||||
|
||||
rootFolder := ""
|
||||
for _, folder := range s.folders {
|
||||
if strings.HasPrefix(fullPath, folder) {
|
||||
if folderContains(folder, fullPath) {
|
||||
rootFolder = folder
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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/utils"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -16,6 +17,143 @@ type SeriesInfo struct {
|
||||
TotalInSeries int
|
||||
CoverPaths []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 {
|
||||
@@ -31,18 +169,35 @@ func NewSeriesService(db *database.Queries) *SeriesService {
|
||||
// Pass libraryID for a single-library page, or leave it invalid and pass
|
||||
// visibleLibraryIDs for an across-my-libraries page. Both filters compose:
|
||||
// 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) {
|
||||
totalCount, err := s.db.GetDistinctSeriesCount(ctx, database.GetDistinctSeriesCountParams{
|
||||
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts SeriesPageOptions) ([]SeriesInfo, int, error) {
|
||||
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,
|
||||
VisibleLibraryIds: visibleLibraryIDs,
|
||||
})
|
||||
SeriesQuery: nargText(opts.Query),
|
||||
AuthorQuery: nargText(opts.Author),
|
||||
Status: status,
|
||||
}
|
||||
totalCount, err := s.db.GetDistinctSeriesCount(ctx, countParams)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := s.db.GetDistinctSeries(ctx, database.GetDistinctSeriesParams{
|
||||
ViewerUserID: opts.ViewerUserID,
|
||||
LibraryID: libraryID,
|
||||
VisibleLibraryIds: visibleLibraryIDs,
|
||||
SeriesQuery: countParams.SeriesQuery,
|
||||
AuthorQuery: countParams.AuthorQuery,
|
||||
Status: status,
|
||||
Sort: sort,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), 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) {
|
||||
totalInSeries = int(row.BookCount)
|
||||
}
|
||||
@@ -87,6 +254,8 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
|
||||
TotalInSeries: totalInSeries,
|
||||
CoverPaths: coverPaths,
|
||||
LastEntryAt: lastEntry,
|
||||
ReadCount: row.FinishedCount,
|
||||
LastReadAt: lastRead,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
|
||||
<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>
|
||||
|
||||
@@ -29,7 +29,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(retentionDays)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -135,7 +135,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -153,7 +153,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -171,7 +171,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -184,7 +184,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.LibraryName)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -197,7 +197,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -215,7 +215,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -228,7 +228,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MatchID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -241,7 +241,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
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")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -267,7 +267,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -288,7 +288,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -9,7 +9,6 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
||||
<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>
|
||||
@@ -267,6 +266,18 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
|
||||
for _, folder := range folders {
|
||||
<div class="flex items-center justify-between gap-2 p-2 rounded-lg" style="background-color: var(--bg-primary);">
|
||||
<code class="text-xs flex-1 truncate" style="color: var(--text-primary)">{ folder.FolderPath }</code>
|
||||
<button
|
||||
class="icon-btn h-7 w-7 shrink-0"
|
||||
hx-post={ "/api/libraries/" + libraryID + "/scan" }
|
||||
hx-vals={ hxValsFolderScan(folder.FolderPath) }
|
||||
hx-target="#scan-indicator"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Scan this folder?"
|
||||
title="Scan this folder"
|
||||
aria-label="Scan this folder"
|
||||
>
|
||||
@Icon("refresh", "h-3.5 w-3.5")
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn h-7 w-7 shrink-0"
|
||||
hx-delete={ "/admin/library/" + libraryID + "/folders" }
|
||||
|
||||
+236
-202
@@ -31,7 +31,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(archivedCount)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -220,7 +220,7 @@ func LibraryTypeOptions(types []database.LibraryTypes) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Name)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -233,7 +233,7 @@ func LibraryTypeOptions(types []database.LibraryTypes) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(LibraryTypeDisplayName(t.Name))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -303,7 +303,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -321,7 +321,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -343,7 +343,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -365,7 +365,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -383,7 +383,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -404,7 +404,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -425,7 +425,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -500,20 +500,20 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</code> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-delete=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</code> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/scan")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 272, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 271, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -524,28 +524,62 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderScan(folder.FolderPath))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 273, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 272, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-target=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-target=\"#scan-indicator\" hx-swap=\"innerHTML\" hx-confirm=\"Scan this folder?\" title=\"Scan this folder\" aria-label=\"Scan this folder\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("refresh", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</button> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-delete=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 274, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 283, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" hx-swap=\"innerHTML\" hx-confirm=\"Remove this folder from the library?\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" hx-vals=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -553,82 +587,82 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<form class=\"flex gap-2\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 124}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" hx-swap=\"innerHTML\"><input type=\"text\" name=\"folder_path\" id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<form class=\"flex gap-2\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 288, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 295, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" placeholder=\"/path/to/books\" class=\"input flex-1\" required> <button type=\"button\" data-library-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 295, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 295, Col: 124}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" data-target-input=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" hx-swap=\"innerHTML\"><input type=\"text\" name=\"folder_path\" id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 296, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 299, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" @click=\"openFolderBrowser($el.dataset.targetInput, $el.dataset.libraryId)\" class=\"btn btn-secondary text-sm shrink-0\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" placeholder=\"/path/to/books\" class=\"input flex-1\" required> <button type=\"button\" data-library-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -636,7 +670,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Browse</button> <button type=\"submit\" class=\"btn btn-primary text-sm shrink-0\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "Browse</button> <button type=\"submit\" class=\"btn btn-primary text-sm shrink-0\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -644,12 +678,12 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "Add</button></form></div><!-- User Visibility -->")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "Add</button></form></div><!-- User Visibility -->")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(users) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<div><div class=\"flex items-center gap-2 mb-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<div><div class=\"flex items-center gap-2 mb-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -657,115 +691,115 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<h4 class=\"text-sm font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">User Access</h4></div><div class=\"space-y-1\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<h4 class=\"text-sm font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">User Access</h4></div><div class=\"space-y-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, u := range users {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<label class=\"flex items-center gap-3 cursor-pointer p-2 rounded-lg transition-colors hover:bg-surface-hover\"><input type=\"checkbox\" class=\"w-4 h-4 rounded\" name=\"is_visible\" value=\"true\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<label class=\"flex items-center gap-3 cursor-pointer p-2 rounded-lg transition-colors hover:bg-surface-hover\"><input type=\"checkbox\" class=\"w-4 h-4 rounded\" name=\"is_visible\" value=\"true\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if isUserVisible(u.ID, visibility) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " checked")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 325, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" hx-vals=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 326, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" hx-trigger=\"change\" hx-target=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 328, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 336, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" hx-swap=\"innerHTML\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" hx-vals=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 331, Col: 76}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" hx-trigger=\"change\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 332, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span></label>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" hx-swap=\"innerHTML\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 342, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 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, 72, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<!-- Processing Issues -->")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<!-- Processing Issues -->")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if issueCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<div><a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 templ.SafeURL
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
|
||||
var templ_7745c5c3_Var33 templ.SafeURL
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 341, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 352, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -774,25 +808,25 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if issueCount == 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span>1 processing issue</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span>1 processing issue</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 346, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " processing issues</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " processing issues</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -801,51 +835,51 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</a></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</a></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<!-- Actions --><div class=\"flex gap-2 pt-2 border-t\" style=\"border-color: var(--border);\"><button data-edit-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 355, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" data-edit-name=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 356, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" data-edit-desc=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<!-- Actions --><div class=\"flex gap-2 pt-2 border-t\" style=\"border-color: var(--border);\"><button data-edit-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 357, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 366, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" @click=\"openEditModal($el.dataset.editId, $el.dataset.editName, $el.dataset.editDesc)\" class=\"btn btn-secondary text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" data-edit-name=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -853,33 +887,33 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "Edit Details</button> <button data-delete-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Edit Details</button> <button data-delete-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 365, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" data-delete-name=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" data-delete-name=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 366, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" @click=\"openDeleteModal($el.dataset.deleteId, $el.dataset.deleteName)\" class=\"btn btn-danger text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" @click=\"openDeleteModal($el.dataset.deleteId, $el.dataset.deleteName)\" class=\"btn btn-danger text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -887,7 +921,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Delete Library</button></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "Delete Library</button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -911,12 +945,12 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var38 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var38 == nil {
|
||||
templ_7745c5c3_Var38 = templ.NopComponent
|
||||
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var40 == nil {
|
||||
templ_7745c5c3_Var40 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<div><div class=\"flex items-center gap-2 mb-3 p-2 rounded-lg\" style=\"background-color: var(--bg-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<div><div class=\"flex items-center gap-2 mb-3 p-2 rounded-lg\" style=\"background-color: var(--bg-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -924,38 +958,38 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<code class=\"text-xs flex-1 truncate\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<code class=\"text-xs flex-1 truncate\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 381, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</code></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</code></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if parentPath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 387, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -963,30 +997,30 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<span>..</span></button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<span>..</span></button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<div class=\"space-y-1 max-h-64 overflow-y-auto\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<div class=\"space-y-1 max-h-64 overflow-y-auto\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\" hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<button class=\"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 400, Col: 118}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -994,51 +1028,51 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"truncate\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<span class=\"truncate\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 405, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 416, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</span></button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</span></button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</div><div class=\"mt-4 flex justify-end\"><button type=\"button\" data-target-input=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</div><div class=\"mt-4 flex justify-end\"><button type=\"button\" data-target-input=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 412, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 423, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" data-current-path=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" data-current-path=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 413, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 424, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" @click=\"pickBrowsePath($event)\" class=\"btn btn-primary text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\" @click=\"pickBrowsePath($event)\" class=\"btn btn-primary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1046,7 +1080,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "Select This Folder</button></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "Select This Folder</button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -12,7 +12,13 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
||||
</head>
|
||||
<body class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/admin/library")
|
||||
<main class="p-8">
|
||||
<!-- The retry queue component: per-issue Retry buttons and Retry all
|
||||
run each issue through the individual scanner sequentially (one
|
||||
book in flight at a time). The whole variable section below is a
|
||||
templ partial (#issues-content) so the client can re-render
|
||||
server truth after the queue drains — htmx.ajax swap, never
|
||||
JSON-into-innerHTML. -->
|
||||
<main class="p-8" x-data="processingIssues" data-library-id={ libraryID }>
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="mb-8">
|
||||
<div>
|
||||
@@ -25,91 +31,126 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Items that couldn't be processed in this library</p>
|
||||
</div>
|
||||
</div>
|
||||
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
if stats.ErrorCount > 0 {
|
||||
<div class="stat-card" style="border-left: 4px solid var(--status-danger);">
|
||||
<div class="flex items-center gap-2 mb-2" style="color: var(--status-danger);">
|
||||
@Icon("x-circle", "h-5 w-5")
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide">Errors</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.ErrorCount }</p>
|
||||
</div>
|
||||
}
|
||||
if stats.WarningCount > 0 {
|
||||
<div class="stat-card" style="border-left: 4px solid var(--status-warning);">
|
||||
<div class="flex items-center gap-2 mb-2" style="color: var(--status-warning);">
|
||||
@Icon("alert", "h-5 w-5")
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide">Warnings</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.WarningCount }</p>
|
||||
</div>
|
||||
}
|
||||
if stats.InfoCount > 0 {
|
||||
<div class="stat-card" style="border-left: 4px solid var(--status-info);">
|
||||
<div class="flex items-center gap-2 mb-2" style="color: var(--status-info);">
|
||||
@Icon("info", "h-5 w-5")
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide">Info</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.InfoCount }</p>
|
||||
</div>
|
||||
}
|
||||
@ProcessingIssuesContent(libraryID, issues, stats)
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
// ProcessingIssuesContent is the swappable body of the Processing Issues
|
||||
// page: the severity stat cards (with the Retry-all control on the right
|
||||
// of the errors row) and the issue card list. Rendered by the page and by
|
||||
// the /admin/libraries/:id/issues/content partial the retry queue swaps
|
||||
// in after draining.
|
||||
templ ProcessingIssuesContent(libraryID string, issues []ProcessingIssueData, stats IssueStats) {
|
||||
<div id="issues-content">
|
||||
if len(issues) > 0 {
|
||||
<div class="flex items-start justify-between gap-6 mb-8">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 flex-1">
|
||||
if stats.ErrorCount > 0 {
|
||||
<div class="stat-card" style="border-left: 4px solid var(--status-danger);">
|
||||
<div class="flex items-center gap-2 mb-2" style="color: var(--status-danger);">
|
||||
@Icon("x-circle", "h-5 w-5")
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide">Errors</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.ErrorCount }</p>
|
||||
</div>
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
<div class="card p-8 text-center">
|
||||
<span class="grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("check-circle", "h-6 w-6")
|
||||
</span>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">No processing issues found for this library.</p>
|
||||
if stats.WarningCount > 0 {
|
||||
<div class="stat-card" style="border-left: 4px solid var(--status-warning);">
|
||||
<div class="flex items-center gap-2 mb-2" style="color: var(--status-warning);">
|
||||
@Icon("alert", "h-5 w-5")
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide">Warnings</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.WarningCount }</p>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Issues List -->
|
||||
<div class="space-y-4">
|
||||
for _, issue := range issues {
|
||||
<div class="card p-6" id={ "issue-" + issue.ID }>
|
||||
<div class="flex justify-between items-start gap-4 mb-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ issue.Title }</h4>
|
||||
<p class="mb-3 text-sm" style="color: var(--text-secondary)">{ issue.IssueDescription }</p>
|
||||
<div class="text-sm space-y-1" style="color: var(--text-secondary)">
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Type:</span> { issue.IssueType }</p>
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Format:</span> { issue.FormatGroup }</p>
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">File:</span> { issue.FilePath }</p>
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Library:</span> { issue.LibraryTypeName }</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 shrink-0">
|
||||
if issue.Severity == "error" {
|
||||
<span class="badge status-failed">{ issue.Severity }</span>
|
||||
} else if issue.Severity == "warning" {
|
||||
<span class="badge status-pending">{ issue.Severity }</span>
|
||||
} else {
|
||||
<span class="badge status-processing">{ issue.Severity }</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||
<button
|
||||
hx-post={ "/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve" }
|
||||
hx-target={ "#issue-" + issue.ID }
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Dismiss this issue?"
|
||||
class="btn btn-secondary text-sm"
|
||||
>
|
||||
@Icon("close", "h-4 w-4")
|
||||
Dismiss
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
if stats.InfoCount > 0 {
|
||||
<div class="stat-card" style="border-left: 4px solid var(--status-info);">
|
||||
<div class="flex items-center gap-2 mb-2" style="color: var(--status-info);">
|
||||
@Icon("info", "h-5 w-5")
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide">Info</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.InfoCount }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
<!-- Retry all: right of the errors row. Queues every issue
|
||||
currently on the page through the individual scanner,
|
||||
one at a time — deliberately NOT a whole-library rescan. -->
|
||||
<button
|
||||
@click="retryAll()"
|
||||
:disabled="queueRunning"
|
||||
class="btn btn-primary shrink-0"
|
||||
>
|
||||
<span x-show="!queueRunning" class="inline-flex items-center gap-2">
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
<span>Retry all</span>
|
||||
</span>
|
||||
<span x-show="queueRunning" x-text="queueRunning ? (queueDone + ' / ' + queueTotal) : ''"></span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
<div class="card p-8 text-center">
|
||||
<span class="grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("check-circle", "h-6 w-6")
|
||||
</span>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">No processing issues found for this library.</p>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Issues List -->
|
||||
<div class="space-y-4">
|
||||
for _, issue := range issues {
|
||||
<div class="card p-6" id={ "issue-" + issue.ID } data-issue-id={ issue.ID }>
|
||||
<div class="flex justify-between items-start gap-4 mb-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ issue.Title }</h4>
|
||||
<p class="mb-3 text-sm" style="color: var(--text-secondary)">{ issue.IssueDescription }</p>
|
||||
<div class="text-sm space-y-1" style="color: var(--text-secondary)">
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Type:</span> { issue.IssueType }</p>
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Format:</span> { issue.FormatGroup }</p>
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">File:</span> { issue.FilePath }</p>
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Library:</span> { issue.LibraryTypeName }</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 shrink-0">
|
||||
if issue.Severity == "error" {
|
||||
<span class="badge status-failed">{ issue.Severity }</span>
|
||||
} else if issue.Severity == "warning" {
|
||||
<span class="badge status-pending">{ issue.Severity }</span>
|
||||
} else {
|
||||
<span class="badge status-processing">{ issue.Severity }</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button
|
||||
class="btn btn-primary text-sm issue-retry-btn"
|
||||
@click={ "retryOne(" + JSONArg(issue.ID) + ")" }
|
||||
:disabled="queueRunning"
|
||||
>
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
Retry
|
||||
</button>
|
||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||
<button
|
||||
hx-post={ "/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve" }
|
||||
hx-target={ "#issue-" + issue.ID }
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Dismiss this issue?"
|
||||
class="btn btn-secondary text-sm"
|
||||
>
|
||||
@Icon("close", "h-4 w-4")
|
||||
Dismiss
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -59,7 +59,20 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<main class=\"p-8\"><div class=\"mx-auto max-w-4xl\"><div class=\"mb-8\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!-- The retry queue component: per-issue Retry buttons and Retry all\n\t\t run each issue through the individual scanner sequentially (one\n\t\t book in flight at a time). The whole variable section below is a\n\t\t templ partial (#issues-content) so the client can re-render\n\t\t server truth after the queue drains — htmx.ajax swap, never\n\t\t JSON-into-innerHTML. --><main class=\"p-8\" x-data=\"processingIssues\" data-library-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 21, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><div class=\"mx-auto max-w-4xl\"><div class=\"mb-8\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -67,17 +80,59 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Processing Issues</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Items that couldn't be processed in this library</p></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Processing Issues</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Items that couldn't be processed in this library</p></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
|
||||
templ_7745c5c3_Err = ProcessingIssuesContent(libraryID, issues, stats).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ProcessingIssuesContent is the swappable body of the Processing Issues
|
||||
// page: the severity stat cards (with the Retry-all control on the right
|
||||
// of the errors row) and the issue card list. Rendered by the page and by
|
||||
// the /admin/libraries/:id/issues/content partial the retry queue swaps
|
||||
// in after draining.
|
||||
func ProcessingIssuesContent(libraryID string, issues []ProcessingIssueData, stats IssueStats) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var5 == nil {
|
||||
templ_7745c5c3_Var5 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div id=\"issues-content\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(issues) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"flex items-start justify-between gap-6 mb-8\"><!-- Stats Cards --><div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 flex-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if stats.ErrorCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-danger);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-danger);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-danger);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-danger);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -85,43 +140,16 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Errors</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Errors</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 37, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 58, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if stats.WarningCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-warning);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-warning);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Warnings</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 46, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -130,25 +158,25 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if stats.InfoCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-info);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-info);\">")
|
||||
if stats.WarningCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-warning);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-warning);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("info", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Info</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Warnings</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 55, Col: 91}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 67, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -157,13 +185,48 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
||||
if stats.InfoCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-info);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-info);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("info", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Info</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><!-- Retry all: right of the errors row. Queues every issue\n\t\t\t\t currently on the page through the individual scanner,\n\t\t\t\t one at a time — deliberately NOT a whole-library rescan. --><button @click=\"retryAll()\" :disabled=\"queueRunning\" class=\"btn btn-primary shrink-0\"><span x-show=\"!queueRunning\" class=\"inline-flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span>Retry all</span></span> <span x-show=\"queueRunning\" x-text=\"queueRunning ? (queueDone + ' / ' + queueTotal) : ''\"></span></button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"card p-8 text-center\"><span class=\"grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"card p-8 text-center\"><span class=\"grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -171,198 +234,232 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No processing issues found for this library.</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No processing issues found for this library.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<!-- Issues List --> <div class=\"space-y-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<!-- Issues List --> <div class=\"space-y-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, issue := range issues {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"card p-6\" id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 71, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\"><div class=\"flex justify-between items-start gap-4 mb-4\"><div class=\"flex-1 min-w-0\"><h4 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 98}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"card p-6\" id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 75, Col: 96}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 107, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p><div class=\"text-sm space-y-1\" style=\"color: var(--text-secondary)\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Type:</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-issue-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(issue.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 140}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 107, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Format:</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"><div class=\"flex justify-between items-start gap-4 mb-4\"><div class=\"flex-1 min-w-0\"><h4 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 144}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 110, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">File:</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 139}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 111, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Library:</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p><div class=\"text-sm space-y-1\" style=\"color: var(--text-secondary)\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Type:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 80, Col: 149}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 113, Col: 137}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></div></div><div class=\"ml-2 shrink-0\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Format:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 114, Col: 141}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">File:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 115, Col: 136}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Library:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 116, Col: 146}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</p></div></div><div class=\"ml-2 shrink-0\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if issue.Severity == "error" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<span class=\"badge status-failed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 85, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if issue.Severity == "warning" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"badge status-pending\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 87, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"badge status-processing\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 89, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></div><div class=\"flex gap-3 mt-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<button hx-post=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"badge status-failed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 113}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 121, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" hx-target=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if issue.Severity == "warning" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span class=\"badge status-pending\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 97, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 123, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" hx-swap=\"outerHTML\" hx-confirm=\"Dismiss this issue?\" class=\"btn btn-secondary text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span class=\"badge status-processing\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 125, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></div><div class=\"flex gap-3 mt-4\"><button class=\"btn btn-primary text-sm issue-retry-btn\" @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("retryOne(" + JSONArg(issue.ID) + ")")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 132, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" :disabled=\"queueRunning\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "Retry</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<button hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 140, Col: 111}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 141, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" hx-swap=\"outerHTML\" hx-confirm=\"Dismiss this issue?\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -370,22 +467,22 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Dismiss</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "Dismiss</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div></main></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
|
||||
<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>
|
||||
@@ -35,7 +34,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
|
||||
<span>{ errorMessage }</span>
|
||||
</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="flex items-center gap-2 mb-6">
|
||||
@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>
|
||||
</div>
|
||||
<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")
|
||||
Save Settings
|
||||
</button>
|
||||
@@ -129,9 +128,9 @@ templ ScanSettingsSection(scanSettings ScanSettingsData) {
|
||||
<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-target="#scan-settings-section"
|
||||
hx-swap="outerHTML"
|
||||
hx-swap="none"
|
||||
>
|
||||
<div class="space-y-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>
|
||||
</div>
|
||||
<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")
|
||||
Save Scan Settings
|
||||
</button>
|
||||
@@ -220,6 +219,7 @@ templ TunableSettingRow(e SettingEntry) {
|
||||
</div>
|
||||
<form
|
||||
class="flex items-center gap-2 shrink-0"
|
||||
data-settings-save="tunable"
|
||||
hx-put="/admin/settings/tunable"
|
||||
hx-target={ "#status-" + e.Key }
|
||||
hx-swap="innerHTML"
|
||||
@@ -252,7 +252,7 @@ templ TunableSettingRow(e SettingEntry) {
|
||||
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")
|
||||
Save
|
||||
</button>
|
||||
@@ -275,3 +275,5 @@ templ AboutSection() {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -103,7 +103,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -118,13 +118,13 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -395,7 +395,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -408,7 +408,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -421,7 +421,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -484,7 +484,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -501,13 +501,13 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -593,7 +593,7 @@ func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.C
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -651,7 +651,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -669,7 +669,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -687,7 +687,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -698,7 +698,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -832,7 +832,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
<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>
|
||||
|
||||
@@ -70,7 +70,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -149,7 +149,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -172,7 +172,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -185,7 +185,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -208,7 +208,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -221,7 +221,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -259,7 +259,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -272,7 +272,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -311,7 +311,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -324,7 +324,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -40,6 +40,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
data-title={ metadata.Title }
|
||||
data-cover={ metadata.CoverImagePath }
|
||||
data-file-url={ metadata.FileURL }
|
||||
data-keep-awake={ metadata.KeepScreenAwake }
|
||||
data-json={ fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress") }
|
||||
|
||||
@@ -125,125 +125,138 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" data-json=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" data-keep-awake=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress"))
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.KeepScreenAwake)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 45, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 43, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts & interactions\" title=\"Shortcuts & interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semLine truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" data-json=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 46, Col: 57}
|
||||
}
|
||||
_, 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, 10, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts & interactions\" title=\"Shortcuts & interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-seLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, rate := range SpeedOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 168, Col: 41}
|
||||
}
|
||||
_, 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, 11, "\" :selected=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("isDefaultSpeed(%g)", rate))
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" :selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("isDefaultSpeed(%g)", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
_, 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, 13, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</select></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if metadata.CoverImagePath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<img src=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 184, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 171, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" alt=\"")
|
||||
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></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if metadata.CoverImagePath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 185, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 185, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 186, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 194, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 195, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"onScrubInput($event)\" @change=\"commitScrub()\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"railTickStyle(tick)\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button type=\"button\" class=\"inline-block min-w-[4.75rem] text-right cursor-pointer\" x-text=\"endTimeText()\" :aria-label=\"endTimeAria()\" :title=\"endTimeAria()\" @click=\"cycleEndTLine truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"onScrubInput($event)\" @change=\"commitScrub()\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"railTickStyle(tick)\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button type=\"button\" class=\"inline-block min-w-[4.75rem] text-right cursor-pointer\" x-text=\"endTimeText()\" :aria-label=\"endTimeAria()\" :title=\"endTimeAria()\" @click=\"cycleEndTLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ book.Title } - 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>
|
||||
@@ -603,6 +602,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
@AnnotationsBanner(book)
|
||||
if user.Role == "admin" {
|
||||
@MetadataEditorModal(book)
|
||||
@WriteToFileConfirmDialog(book)
|
||||
}
|
||||
<!-- Review editor: plain textarea over the RAW markdown (what
|
||||
storage and sync carry); rendering happens only on display. -->
|
||||
|
||||
@@ -251,6 +251,7 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
id="metadata-editor-modal"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
data-write-file-path={ book.FilePath }
|
||||
>
|
||||
<div
|
||||
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>
|
||||
</svg>
|
||||
</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 class="flex space-x-3">
|
||||
<button
|
||||
@@ -703,3 +716,52 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FormatGroup)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -115,7 +115,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(book.LibraryID))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -128,7 +128,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", getBookRating(book.Rating)))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -141,7 +141,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflictID(book.ActiveConflict))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -154,7 +154,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflictWinnerSource(book.ActiveConflict))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -194,7 +194,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(templ.SafeCSS("background-image: url(" + cssURL(book.CoverImagePath.String) + ")"))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -225,7 +225,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath.String)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -238,7 +238,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -256,7 +256,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -274,7 +274,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -292,7 +292,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.Subtitle.String)
|
||||
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))
|
||||
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
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs("/bookshelf?author_filter=" + url.QueryEscape(authorName))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -335,7 +335,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(authorName)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -358,7 +358,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author.String)
|
||||
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))
|
||||
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
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + uuidToString(book.ID))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -423,7 +423,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.ActionLabel())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -449,7 +449,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.MarkResetLabel())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -475,7 +475,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.MarkCompleteLabel())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -520,7 +520,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(len(book.DeletedAnnotations))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -551,7 +551,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(book.NotesCount + book.HighlightsCount)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -600,7 +600,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64))
|
||||
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))
|
||||
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
|
||||
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 {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -642,7 +642,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(book.Series.String)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -660,7 +660,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(book.SeriesNumber.Float64))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -680,7 +680,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToUpper(book.ReadingDirection.String))
|
||||
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))
|
||||
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
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs("/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -739,7 +739,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(tag)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -767,7 +767,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Review.Review)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -818,7 +818,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(book.MetadataNotes.String)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -841,7 +841,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(book.ProgressHeader())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -872,7 +872,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
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))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -890,7 +890,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioCurrentLabel() + " / " + book.AudioDurationLabel())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -908,7 +908,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -927,7 +927,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.CurrentPage.Int32)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -940,7 +940,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.TotalPages.Int32)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -959,7 +959,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastReadLabel())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -972,7 +972,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -991,7 +991,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.LastSyncSource.String)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1019,7 +1019,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(book.StatsHeader())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1032,7 +1032,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(book.TimeStatLabel())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1050,7 +1050,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
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))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1068,7 +1068,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d min", book.BookStats.TotalMinutes))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1086,7 +1086,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var47 string
|
||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.SessionCount))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1104,7 +1104,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var48 string
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(book.TimeListenedLabel())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1122,7 +1122,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var49 string
|
||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.TotalPages))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1179,7 +1179,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var50 string
|
||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(book.DatePublished.Time.Format("01-02-2006"))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1198,7 +1198,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var51 string
|
||||
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(book.Isbn.String)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1257,7 +1257,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var52 string
|
||||
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioDurationLabel())
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1296,7 +1296,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var53 string
|
||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(book.PageCount.Int32)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1329,7 +1329,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var54 string
|
||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesCount.Int32)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1348,7 +1348,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var55 string
|
||||
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(book.Volume.Int32)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1381,7 +1381,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var56 string
|
||||
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(book.CopyrightYear.Int32)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1400,7 +1400,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var57 string
|
||||
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ReplaceAll(book.MangaType.String, "_", " "))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1419,7 +1419,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var58 string
|
||||
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(book.ScanInformation.String)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1438,7 +1438,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var59 string
|
||||
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(altSeries)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1457,7 +1457,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var60 string
|
||||
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(book.Contributors, ", "))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1475,7 +1475,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var61 string
|
||||
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(book.MimeType.String)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1493,7 +1493,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var62 string
|
||||
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(formatFileSize(book.FileSize.Int64))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1512,7 +1512,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var63 string
|
||||
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(book.FileLocation)
|
||||
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))
|
||||
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
|
||||
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author))
|
||||
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))
|
||||
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
|
||||
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author))
|
||||
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))
|
||||
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
|
||||
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author))
|
||||
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))
|
||||
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
|
||||
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author))
|
||||
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))
|
||||
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
|
||||
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author))
|
||||
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))
|
||||
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
|
||||
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author))
|
||||
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))
|
||||
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
|
||||
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author))
|
||||
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))
|
||||
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
|
||||
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("amazon", "", book.Isbn, book.Title, book.Author))
|
||||
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))
|
||||
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
|
||||
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinURLErrs(book.WebUrl.String)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1701,7 +1701,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var73 string
|
||||
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(getDomainName(book.WebUrl.String))
|
||||
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))
|
||||
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
|
||||
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinURLErrs("/collections/" + uuidToString(col.ID))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1747,7 +1747,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
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;")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1760,7 +1760,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var76 string
|
||||
templ_7745c5c3_Var76, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("color: " + col.Color.String)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1773,7 +1773,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var77 string
|
||||
templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon.String)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1786,7 +1786,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
var templ_7745c5c3_Var78 string
|
||||
templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1823,8 +1823,16 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 = WriteToFileConfirmDialog(book).Render(ctx, templ_7745c5c3_Buffer)
|
||||
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 = 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1832,7 +1840,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ templ BookShelf(
|
||||
<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>
|
||||
|
||||
@@ -45,7 +45,7 @@ func BookShelf(
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -132,7 +132,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -195,7 +195,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -213,7 +213,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -234,7 +234,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -247,7 +247,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -260,7 +260,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -278,7 +278,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -291,7 +291,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -304,7 +304,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -323,7 +323,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -336,7 +336,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -349,7 +349,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -367,7 +367,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -380,7 +380,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -393,7 +393,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -422,7 +422,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -445,7 +445,7 @@ func BookShelf(
|
||||
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))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -476,7 +476,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -489,7 +489,7 @@ func BookShelf(
|
||||
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))
|
||||
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)
|
||||
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 name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Collection Rules - { collection.Name } - 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>
|
||||
|
||||
@@ -42,7 +42,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "')")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -100,7 +100,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: var(--accent-muted); color: " + collection.Color)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -113,7 +113,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -126,7 +126,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -147,7 +147,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -97,7 +97,6 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
<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>
|
||||
@@ -212,13 +211,12 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ collection.Name } - 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>
|
||||
<body x-data="collections" x-init="initCollectionsPage()" class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/collections")
|
||||
@LibrarySwitcher(libData, libraryID, "")
|
||||
@LibrarySwitcher(libData, libraryID, "", nil)
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/collections" class="btn btn-secondary mb-4">
|
||||
|
||||
@@ -307,7 +307,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
templ_7745c5c3_Var15 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -395,7 +395,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -408,7 +408,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -421,7 +421,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -444,7 +444,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -465,7 +465,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -486,7 +486,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -499,7 +499,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -512,7 +512,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount)))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -577,13 +577,13 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -613,7 +613,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = LibrarySwitcher(libData, libraryID, "").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = LibrarySwitcher(libData, libraryID, "", nil).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -632,7 +632,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: var(--accent-muted); color: " + collection.Color)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -645,7 +645,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -658,7 +658,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -681,7 +681,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -709,7 +709,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var34 templ.SafeURL
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs("/collections/" + collection.ID + "/rules")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -778,7 +778,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -791,7 +791,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -804,7 +804,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -825,7 +825,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -851,7 +851,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -864,7 +864,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -877,7 +877,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -890,7 +890,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSmart)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -933,7 +933,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -951,7 +951,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -964,7 +964,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -982,7 +982,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var47 string
|
||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1000,7 +1000,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var48 string
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1013,7 +1013,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var49 string
|
||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1026,7 +1026,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var50 string
|
||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -92,7 +92,6 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
<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>
|
||||
|
||||
@@ -323,7 +323,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
templ_7745c5c3_Var15 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
||||
<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>
|
||||
|
||||
@@ -29,7 +29,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -100,7 +100,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
+73
-40
@@ -12,13 +12,12 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Dashboard - 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>
|
||||
<body x-data="dashboard" x-init="initDashboard()" class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/dashboard")
|
||||
@LibrarySwitcher(libData, currentLibraryID, "Your Library", DashboardActions())
|
||||
@LibrarySwitcher(libData, currentLibraryID, "Your Library", weekWidgetComponent(week, activeLibType), DashboardActions())
|
||||
<!-- OUTSIDE #collections-container on purpose: the switcher's
|
||||
re-render wipes that container's innerHTML, and anything
|
||||
server-rendered inside it dies on the first library
|
||||
@@ -41,11 +40,6 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
if week != nil {
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 pt-6">
|
||||
@DashboardWeekWidget(*week, activeLibType)
|
||||
</div>
|
||||
}
|
||||
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 pt-5 pb-8">
|
||||
for _, section := range sections {
|
||||
@CollectionCarousel(section)
|
||||
@@ -149,7 +143,7 @@ templ BookCard(item handlers.BookInfo) {
|
||||
/>
|
||||
}
|
||||
<!-- Media-type identifier (A2c): books stay unbadged — the
|
||||
shelf default already reads as a book -->
|
||||
shelf default already reads as a book -->
|
||||
if MediaBadgeIcon(item.Badge) != "" {
|
||||
<span
|
||||
class="absolute top-2 right-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm"
|
||||
@@ -159,6 +153,25 @@ templ BookCard(item handlers.BookInfo) {
|
||||
@Icon(MediaBadgeIcon(item.Badge), "h-4 w-4")
|
||||
</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 class="book-card-meta">
|
||||
<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
|
||||
}
|
||||
|
||||
// DashboardWeekWidget renders the collapsible "This Week" strip. The
|
||||
// collapsed summary row reads at a glance on every screen size; the
|
||||
// expanded body holds the full grid. The details element re-fetches
|
||||
// itself through htmx when the body sees a `week-refresh` event
|
||||
// (dispatched by the library switcher), carrying the CURRENT
|
||||
// #library-select value, so the widget tracks the switched library.
|
||||
// weekWidgetComponent adapts the optional week stats into the
|
||||
// LibrarySwitcher's center slot. nil (stats unavailable or the stats
|
||||
// service is off) = the slot stays empty, exactly as when the widget
|
||||
// used to be suppressed entirely.
|
||||
func weekWidgetComponent(week *WeekStatsData, activeLibType string) templ.Component {
|
||||
if week == nil {
|
||||
return nil
|
||||
}
|
||||
return DashboardWeekWidget(*week, activeLibType)
|
||||
}
|
||||
|
||||
// DashboardWeekWidget renders the "This Week" stats that live in the
|
||||
// library bar's center slot (LibrarySwitcher's `center` component).
|
||||
// The summary is a compact inline row whose numbers reveal themselves
|
||||
// as width allows; the expanded body is an absolutely positioned
|
||||
// popover that FLOATS over the page content — opening it never shifts
|
||||
// the collections below. The details element re-fetches itself through
|
||||
// htmx when the body sees a `week-refresh` event (dispatched by the
|
||||
// library switcher), carrying the CURRENT #library-select value, so
|
||||
// the widget tracks the switched library. Visible at every width — on
|
||||
// narrow screens the summary collapses to the chart icon + chevron
|
||||
// (tap target, aria-labeled; "This Week" + minutes reveal at `sm`,
|
||||
// pages/sessions/books at md/lg/xl). The panel anchors to the
|
||||
// BAR (full-width under it) below `sm` — the widget is too far left on
|
||||
// phones for widget-centered anchoring — and centers on the widget at
|
||||
// `sm` and up.
|
||||
templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
|
||||
<details
|
||||
id="week-widget"
|
||||
class="card group"
|
||||
class="group sm:relative"
|
||||
hx-get="/dashboard/week-widget"
|
||||
hx-trigger="week-refresh from:body"
|
||||
hx-include="#library-select"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<summary class="px-4 md:px-6 py-3 flex flex-wrap items-center gap-x-5 gap-y-1 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
|
||||
<span class="flex items-center gap-2 font-bold tracking-tight" style="color: var(--text-primary)">
|
||||
<span class="grid place-items-center h-6 w-6 rounded-md" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("chart", "h-4 w-4")
|
||||
</span>
|
||||
This Week
|
||||
<summary class="flex items-center gap-2 cursor-pointer select-none list-none rounded-lg px-2 py-1 hover:bg-surface-hover [&::-webkit-details-marker]:hidden" aria-label="Reading stats for this week">
|
||||
<span class="grid place-items-center h-6 w-6 rounded-md shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("chart", "h-4 w-4")
|
||||
</span>
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</span>
|
||||
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d pages", week.WeekPages) }</span>
|
||||
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d sessions", week.WeekSessions) }</span>
|
||||
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d books", week.WeekBooks) }</span>
|
||||
<span class="text-sm font-bold tracking-tight whitespace-nowrap hidden sm:inline" style="color: var(--text-primary)">This Week</span>
|
||||
<span class="text-sm font-semibold whitespace-nowrap hidden sm:inline" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</span>
|
||||
<span class="text-xs whitespace-nowrap hidden md:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d pages", week.WeekPages) }</span>
|
||||
<span class="text-xs whitespace-nowrap hidden lg:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d sessions", week.WeekSessions) }</span>
|
||||
<span class="text-xs whitespace-nowrap hidden xl:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d books", week.WeekBooks) }</span>
|
||||
if week.CurrentStreak > 0 {
|
||||
<span class="text-sm font-medium" style="color: var(--accent)">🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak</span>
|
||||
<span class="text-xs font-medium whitespace-nowrap hidden lg:inline" style="color: var(--accent)">🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak</span>
|
||||
}
|
||||
<span class="ml-auto flex items-center gap-3">
|
||||
<a href="/stats" class="text-xs font-medium hover:underline" style="color: var(--accent);" @click.stop>Full stats</a>
|
||||
@Icon("chevron-down", "h-4 w-4 transition-transform group-open:rotate-180")
|
||||
</span>
|
||||
@Icon("chevron-down", "h-4 w-4 shrink-0 transition-transform group-open:rotate-180")
|
||||
</summary>
|
||||
<div class="px-4 md:px-6 pb-4 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
|
||||
<div class="card absolute top-full left-4 right-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 mt-2 sm:w-[30rem] max-w-[calc(100vw-2rem)] z-40 p-4 md:px-6 md:py-5" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
if activeLibType == "audiobooks" {
|
||||
<div>
|
||||
@@ -387,16 +415,21 @@ templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="text-xs mt-3" style="color: var(--text-secondary)">
|
||||
Synced from your reading devices
|
||||
if week.CurrentStreak > 0 {
|
||||
if week.ActiveToday {
|
||||
{ fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak) }
|
||||
} else {
|
||||
{ fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak) }
|
||||
<div class="flex items-end justify-between gap-4 mt-3 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
|
||||
<p class="text-xs" style="color: var(--text-secondary)">
|
||||
Synced from your reading devices
|
||||
if week.CurrentStreak > 0 {
|
||||
if week.ActiveToday {
|
||||
{ fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak) }
|
||||
} else {
|
||||
{ fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak) }
|
||||
}
|
||||
}
|
||||
}
|
||||
</p>
|
||||
</p>
|
||||
<a href="/stats" class="inline-flex items-center gap-1 text-xs font-medium hover:underline shrink-0 whitespace-nowrap" style="color: var(--accent);">Full stats
|
||||
@Icon("arrow-right", "h-4 w-4")
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
|
||||
+387
-339
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 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>
|
||||
|
||||
+21
-21
@@ -31,7 +31,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue("showShelfMappings('" + device.ID.String() + "')")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -154,7 +154,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -175,7 +175,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -188,7 +188,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -221,7 +221,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -249,7 +249,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -277,7 +277,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("sync-url-" + device.ID.String())
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -290,7 +290,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -303,7 +303,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyFromId('sync-url-" + device.ID.String() + "', 'Kobo sync URL')")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -330,7 +330,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("auth-token-" + device.ID.String())
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -343,7 +343,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -356,7 +356,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyFromId('auth-token-" + device.ID.String() + "', 'Auth token')")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -382,7 +382,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -423,7 +423,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -436,7 +436,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -449,7 +449,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -462,7 +462,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -475,7 +475,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -514,7 +514,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -527,7 +527,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -72,7 +72,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
||||
</main>
|
||||
</div>
|
||||
<!-- 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>
|
||||
</html>
|
||||
}
|
||||
|
||||
+235
-222
@@ -264,7 +264,20 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -291,12 +304,12 @@ func DocsTopbar(user User) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var12 == nil {
|
||||
templ_7745c5c3_Var12 = templ.NopComponent
|
||||
templ_7745c5c3_Var13 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var13 == nil {
|
||||
templ_7745c5c3_Var13 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -304,7 +317,7 @@ func DocsTopbar(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -312,7 +325,7 @@ func DocsTopbar(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -320,7 +333,7 @@ func DocsTopbar(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -328,25 +341,25 @@ func DocsTopbar(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("Signed in as " + user.Username)
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("Signed in as " + user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -354,17 +367,17 @@ func DocsTopbar(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} 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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -391,12 +404,12 @@ func DocsSearchBox() templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var14 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var14 == nil {
|
||||
templ_7745c5c3_Var14 = templ.NopComponent
|
||||
templ_7745c5c3_Var15 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var15 == nil {
|
||||
templ_7745c5c3_Var15 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -404,7 +417,7 @@ func DocsSearchBox() templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -432,84 +445,84 @@ func DocsThemePicker() templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var15 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var15 == nil {
|
||||
templ_7745c5c3_Var15 = templ.NopComponent
|
||||
templ_7745c5c3_Var16 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var16 == nil {
|
||||
templ_7745c5c3_Var16 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, opt := range ThemeOptionsFor(ModeDark) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<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, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</option>")
|
||||
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=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</option>")
|
||||
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, 47, "</optgroup></select></div>")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
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 {
|
||||
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_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</optgroup></select></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -536,17 +549,17 @@ func DocsSidebar(nav Navigation, currentPath string) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var20 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var20 == nil {
|
||||
templ_7745c5c3_Var20 = templ.NopComponent
|
||||
templ_7745c5c3_Var21 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var21 == nil {
|
||||
templ_7745c5c3_Var21 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -554,33 +567,33 @@ func DocsSidebar(nav Navigation, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</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=\"")
|
||||
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_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 {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -588,101 +601,101 @@ func DocsSidebar(nav Navigation, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(collapsibleState(section.Collapsed))
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collapsibleState(section.Collapsed))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, item := range section.Items {
|
||||
var templ_7745c5c3_Var24 = []any{docsNavClass(currentPath, item.URL, "pl-7")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...)
|
||||
var templ_7745c5c3_Var25 = []any{docsNavClass(currentPath, item.URL, "pl-7")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var25...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 templ.SafeURL
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||
var templ_7745c5c3_Var26 templ.SafeURL
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span></a> ")
|
||||
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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"><span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -690,84 +703,84 @@ func DocsSidebar(nav Navigation, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(collapsibleState(group.Collapsed))
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(collapsibleState(group.Collapsed))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, item := range group.Items {
|
||||
var templ_7745c5c3_Var31 = []any{docsNavClass(currentPath, item.URL, "pl-9")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var31...)
|
||||
var templ_7745c5c3_Var32 = []any{docsNavClass(currentPath, item.URL, "pl-9")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var32 templ.SafeURL
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||
var templ_7745c5c3_Var33 templ.SafeURL
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" 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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: 223, Col: 46}
|
||||
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_Var34))
|
||||
_, 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></a>")
|
||||
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 {
|
||||
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_Var35))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div></div>")
|
||||
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, 70, "</nav></aside>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</nav></aside>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -794,77 +807,77 @@ func DocsSearchResults(results []SearchResultItem) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var35 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var35 == nil {
|
||||
templ_7745c5c3_Var35 = templ.NopComponent
|
||||
templ_7745c5c3_Var36 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var36 == nil {
|
||||
templ_7745c5c3_Var36 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var36 templ.SafeURL
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(r.URL)
|
||||
var templ_7745c5c3_Var37 templ.SafeURL
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(r.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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
|
||||
}
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, 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_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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, 76, "<span class=\"block text-xs\" style=\"color: var(--text-secondary);\">")
|
||||
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_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Category)
|
||||
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_Var38))
|
||||
_, 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, 77, "</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -891,12 +904,12 @@ func DocsNotFound(title string, message string) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var39 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var39 == nil {
|
||||
templ_7745c5c3_Var39 = templ.NopComponent
|
||||
templ_7745c5c3_Var40 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var40 == nil {
|
||||
templ_7745c5c3_Var40 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -904,20 +917,20 @@ func DocsNotFound(title string, message string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
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: 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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -925,33 +938,33 @@ func DocsNotFound(title string, message string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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);\">")
|
||||
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);\">")
|
||||
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_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 {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+33
-4
@@ -4,6 +4,12 @@ templ Header(user User, currentPath string) {
|
||||
<div
|
||||
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 -->
|
||||
<div
|
||||
class="app-sidebar-backdrop lg:hidden"
|
||||
@@ -224,23 +230,46 @@ templ Header(user User, currentPath string) {
|
||||
>
|
||||
@Icon("menu", "h-5 w-5")
|
||||
</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);">
|
||||
@Icon("search", "h-4 w-4")
|
||||
</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=<commit-ish stamp> busts stale intermediate
|
||||
builds across app restarts (browser caches are keyed by URL) -->
|
||||
<script type="module" src="/static/main.js?v=7"></script>
|
||||
<!-- module bundle; ?v=<content hash> busts stale intermediate
|
||||
builds across app restarts (browser caches are keyed by URL);
|
||||
the hash is computed at startup from the served file -->
|
||||
<script type="module" src={ "/static/main.js?v=" + MainJSVersion() }></script>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
+56
-43
@@ -29,7 +29,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -254,7 +254,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("sidebarShell('" + string(ThemeModeOf(user.Theme)) + "')")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -298,7 +298,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -319,7 +319,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeDark)))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -332,7 +332,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -353,7 +353,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeLight)))
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -371,7 +371,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("themeMode === '" + string(opt.Mode) + "'")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -384,7 +384,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -397,7 +397,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(opt.Mode))
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -410,7 +410,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -423,7 +423,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -476,7 +476,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -489,7 +489,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -512,7 +512,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
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;")
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -530,7 +530,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -717,7 +717,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -725,7 +725,20 @@ func Header(user User, currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -750,12 +763,12 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var42 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var42 == nil {
|
||||
templ_7745c5c3_Var42 = templ.NopComponent
|
||||
templ_7745c5c3_Var43 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var43 == nil {
|
||||
templ_7745c5c3_Var43 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -763,20 +776,20 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -784,7 +797,7 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -792,7 +805,7 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -800,7 +813,7 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -826,12 +839,12 @@ func SidebarSignIn(currentPath string) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var44 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var44 == nil {
|
||||
templ_7745c5c3_Var44 = templ.NopComponent
|
||||
templ_7745c5c3_Var45 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var45 == nil {
|
||||
templ_7745c5c3_Var45 = templ.NopComponent
|
||||
}
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -839,7 +852,7 @@ func SidebarSignIn(currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -847,30 +860,30 @@ func SidebarSignIn(currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package templates
|
||||
|
||||
templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, actions ...templ.Component) {
|
||||
// LibrarySwitcher renders the sticky subbar: title + library select on
|
||||
// the left (title hidden below `sm` — phones need the row width for the
|
||||
// select + actions + the dashboard's stats widget), optional action
|
||||
// buttons on the right. `center` is an
|
||||
// optional slot between them (nil = unused) — the dashboard passes its
|
||||
// "This Week" stats widget here so it participates in the bar's
|
||||
// justify-between layout as the middle group.
|
||||
templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, center templ.Component, actions ...templ.Component) {
|
||||
<div
|
||||
class="app-subbar backdrop-blur border-b"
|
||||
style="background-color: color-mix(in srgb, var(--bg-primary) 88%, transparent); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);"
|
||||
@@ -8,7 +15,7 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-3 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
if title != "" {
|
||||
<span class="text-base font-bold tracking-tight shrink-0" style="color: var(--text-primary)">{ title }</span>
|
||||
<span class="text-base font-bold tracking-tight shrink-0 hidden sm:inline" style="color: var(--text-primary)">{ title }</span>
|
||||
} else {
|
||||
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library</label>
|
||||
}
|
||||
@@ -33,6 +40,9 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
if center != nil {
|
||||
@center
|
||||
}
|
||||
if len(actions) > 0 {
|
||||
<div class="flex items-center gap-2">
|
||||
for _, action := range actions {
|
||||
|
||||
@@ -8,7 +8,14 @@ package templates
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, actions ...templ.Component) templ.Component {
|
||||
// LibrarySwitcher renders the sticky subbar: title + library select on
|
||||
// the left (title hidden below `sm` — phones need the row width for the
|
||||
// select + actions + the dashboard's stats widget), optional action
|
||||
// buttons on the right. `center` is an
|
||||
// optional slot between them (nil = unused) — the dashboard passes its
|
||||
// "This Week" stats widget here so it participates in the bar's
|
||||
// justify-between layout as the middle group.
|
||||
func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title string, center templ.Component, actions ...templ.Component) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
@@ -34,14 +41,14 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if title != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"text-base font-bold tracking-tight shrink-0\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"text-base font-bold tracking-tight shrink-0 hidden sm:inline\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 11, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 18, Col: 122}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -64,7 +71,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 20, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 27, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -84,7 +91,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 24, Col: 29}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -97,7 +104,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 24, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -110,7 +117,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 24, Col: 71}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -128,7 +135,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 26, Col: 29}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -141,7 +148,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 26, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -154,7 +161,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 26, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 33, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -173,7 +180,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 29, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -186,7 +193,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 29, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -199,7 +206,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 29, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 36, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -217,7 +224,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -230,7 +237,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -243,7 +250,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 31, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 38, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -259,6 +266,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if center != nil {
|
||||
templ_7745c5c3_Err = center.Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(actions) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestDashboardUnlinkedBanner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The unlinked-books page renders queue rows standalone.
|
||||
// The unlinked-books page renders queue rows standalone, both buckets.
|
||||
func TestUnlinkedBooksPageRender(t *testing.T) {
|
||||
var b strings.Builder
|
||||
user := User{ID: "u1", Timezone: "UTC"}
|
||||
@@ -81,11 +81,26 @@ func TestUnlinkedBooksPageRender(t *testing.T) {
|
||||
TitleFromDevice: "Some Sideloaded Book",
|
||||
LastSyncTime: "2026-10-18 12:00",
|
||||
}}
|
||||
if err := UnlinkedBooks(user, books).Render(context.Background(), &b); err != nil {
|
||||
ignored := []UnlinkedBookData{{
|
||||
ProgressID: "ub2",
|
||||
DeviceName: "KOReader Phone",
|
||||
DeviceType: "koreader",
|
||||
FilePath: "/sdcard/ignored.epub",
|
||||
SHA256: "def456",
|
||||
TitleFromDevice: "Some Dismissed Book",
|
||||
LastSyncTime: "2026-10-18 12:00",
|
||||
}}
|
||||
if err := UnlinkedBooks(user, books, ignored).Render(context.Background(), &b); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
out := b.String()
|
||||
for _, want := range []string{"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches"} {
|
||||
for _, want := range []string{
|
||||
"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches",
|
||||
// Dismiss/delete actions and the ignored recovery bucket.
|
||||
"Some Dismissed Book", `hx-post="/unlinked-books/ub1/ignore"`,
|
||||
`hx-delete="/unlinked-books/ub1"`, `hx-post="/unlinked-books/ub2/unignore"`,
|
||||
"Ignored (1)",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("unlinked page missing %q", want)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ templ Profile(user User) {
|
||||
<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>
|
||||
|
||||
@@ -87,6 +87,16 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Keep Screen Awake</label>
|
||||
<select
|
||||
name="keep_screen_awake"
|
||||
class="input"
|
||||
>
|
||||
<option value="true" selected?={ user.KeepScreenAwake }>On</option>
|
||||
<option value="false" selected?={ !user.KeepScreenAwake }>Off</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label>
|
||||
<select
|
||||
|
||||
@@ -228,277 +228,297 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Keep Screen Awake</label> <select name=\"keep_screen_awake\" class=\"input\"><option value=\"true\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "UTC" {
|
||||
if user.KeepScreenAwake {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">On</option> <option value=\"false\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Pacific/Honolulu" {
|
||||
if !user.KeepScreenAwake {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Off</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/Anchorage" {
|
||||
if user.Timezone == "UTC" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/Los_Angeles" {
|
||||
if user.Timezone == "Pacific/Honolulu" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/Denver" {
|
||||
if user.Timezone == "America/Anchorage" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/Phoenix" {
|
||||
if user.Timezone == "America/Los_Angeles" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/Chicago" {
|
||||
if user.Timezone == "America/Denver" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/New_York" {
|
||||
if user.Timezone == "America/Phoenix" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/Sao_Paulo" {
|
||||
if user.Timezone == "America/Chicago" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Europe/London" {
|
||||
if user.Timezone == "America/New_York" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Europe/Paris" {
|
||||
if user.Timezone == "America/Sao_Paulo" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Europe/Helsinki" {
|
||||
if user.Timezone == "Europe/London" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Europe/Moscow" {
|
||||
if user.Timezone == "Europe/Paris" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Tehran" {
|
||||
if user.Timezone == "Europe/Helsinki" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Dubai" {
|
||||
if user.Timezone == "Europe/Moscow" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Karachi" {
|
||||
if user.Timezone == "Asia/Tehran" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Kolkata" {
|
||||
if user.Timezone == "Asia/Dubai" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Dhaka" {
|
||||
if user.Timezone == "Asia/Karachi" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Bangkok" {
|
||||
if user.Timezone == "Asia/Kolkata" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Shanghai" {
|
||||
if user.Timezone == "Asia/Dhaka" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Tokyo" {
|
||||
if user.Timezone == "Asia/Bangkok" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Australia/Darwin" {
|
||||
if user.Timezone == "Asia/Shanghai" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Australia/Sydney" {
|
||||
if user.Timezone == "Asia/Tokyo" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Pacific/Auckland" {
|
||||
if user.Timezone == "Australia/Darwin" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, ">New Zealand (UTC+12/+13)</option></select></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Australia/Sydney" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Pacific/Auckland" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, ">New Zealand (UTC+12/+13)</option></select></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if showRoleField {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "user" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, ">User</option> <option value=\"admin\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, ">User</option> <option value=\"admin\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, ">Admin</option></select></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, ">Admin</option></select></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -506,12 +526,12 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if requireCurrentPassword {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"/api/auth/password\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"/api/auth/password\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -519,25 +539,25 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 211, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 221, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -545,22 +565,22 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if showCancelButton {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<button type=\"submit\" class=\"btn btn-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<button type=\"submit\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -568,7 +588,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user