Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7dc8189a1 | ||
|
|
a8641ab343 | ||
|
|
1ce66b2213 | ||
|
|
18f52d240b | ||
|
|
220d97cfe0 | ||
|
|
38efbff7e1 | ||
|
|
f4414aa5b6 | ||
|
|
3daa834a94 | ||
|
|
202a090811 | ||
|
|
ee98c0fb3b | ||
|
|
79ce5ab89d | ||
|
|
6216bd9bcf | ||
|
|
24228e7147 | ||
|
|
e1153b2c4b | ||
|
|
5b81e19f2f | ||
|
|
30ecdd4bf3 | ||
|
|
b2220756e2 | ||
|
|
d74db03cf3 | ||
|
|
9e831085ef | ||
|
|
327fc2c0f9 | ||
|
|
a31da89df5 | ||
|
|
a11b4a9cc4 | ||
|
|
040c10ac76 | ||
|
|
4a68fe7cab | ||
|
|
cb4e892b10 | ||
|
|
506a04b55d | ||
|
|
f1844591b4 | ||
|
|
1fdf306f2d | ||
|
|
b89eac22e3 | ||
|
|
d5bb874651 | ||
|
|
4f913da0b1 | ||
|
|
0799cca3e6 |
No files matched your search
@@ -82,3 +82,7 @@ session-*.md
|
||||
|
||||
# Local prod-export scratch: personal annotation/reading data, never commit
|
||||
/export/
|
||||
|
||||
# Binary media fixtures: never commit (generate with ffmpeg at test time,
|
||||
# see docs/contributing/development.md → Media fixtures)
|
||||
internal/services/testdata/
|
||||
@@ -4,6 +4,43 @@ 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).
|
||||
|
||||
## Rendering ownership (templ vs TypeScript) — ALWAYS apply
|
||||
|
||||
The point of templ + htmx + Alpine is: the server renders the UI, and
|
||||
JavaScript is kept minimal. The rule that keeps it that way:
|
||||
|
||||
> **JavaScript decides what shows. templ decides what it looks like.**
|
||||
|
||||
- Content derived from **server data** (DB rows, search results, match
|
||||
lists, conflict cards, any list/card/badge/table) is rendered by
|
||||
templ. When it changes client-side, fetch a **templ partial** from an
|
||||
SSR route and swap it in (`htmx.ajax(...)`), never rebuild it from a
|
||||
JSON payload with `innerHTML` string templates.
|
||||
- **Pure functions of client-local state** (open/closed panels, tabs,
|
||||
password strength from a typed value, toasts) may render in
|
||||
TS/Alpine — no server round trip needed.
|
||||
- `web/src` is **TypeScript only** — no new `.js` source files.
|
||||
Vendored third-party and vite build output under `web/static` are
|
||||
exempt.
|
||||
- Inline `<script>` in a `.templ` file needs a reason a bundled module
|
||||
cannot do the job (pre-paint execution). Currently sanctioned:
|
||||
`theme_flash_guard.templ` only.
|
||||
|
||||
Sanctioned JS islands (engines, not UI renderers — exempt from the
|
||||
above): the reader (foliate-js + `web/src/reader/`), Chart.js graphs in
|
||||
stats, toasts, password validation. Anything else that renders app UI
|
||||
in TypeScript is a defect; if a templ twin of it exists, one of the two
|
||||
must be deleted.
|
||||
|
||||
**Documented exception — dashboard sections**: `renderDashboardCollections`
|
||||
/ `renderSectionHTML` in `web/src/dashboard.ts` re-render dashboard
|
||||
sections from `/api/dashboard/sections` JSON (library switch, settings
|
||||
save, refresh button) to preserve the switch fade/transition on a page
|
||||
with heavy interactivity. First-paint section markup is still templ
|
||||
(Dashboard handler in `internal/router/frontend.go`). This exception is
|
||||
deliberate; do not clone it to new pages, and do not "fix" the dashboard
|
||||
without restoring a templ-partial fade path first.
|
||||
|
||||
## Library visibility (family-lens rule) — ALWAYS apply
|
||||
|
||||
Per-user library visibility (`library_visibility` table, default-ALLOW:
|
||||
@@ -57,6 +94,24 @@ were name-only across ALL libraries. Fixed; do not regress.
|
||||
cleanups** — sync_integration's `DELETE FROM reading_history WHERE
|
||||
true` destroyed the live reader's stats data on every suite run
|
||||
until scoped to the test-sync users (cascade via user delete).
|
||||
- **RUN ONLY YOUR OWN TESTS while iterating**: integration tests
|
||||
require the containerized dev DB — a bare host `go test
|
||||
./cmd/server/tests` fails on auth on every machine not running the
|
||||
compose stack. Use the filtered container runner
|
||||
(`docker compose -f docker-compose.yml -f
|
||||
docker-compose.dev.yml --profile tests run --rm tests go test
|
||||
./cmd/server/tests -run 'TestYourPrefix' -v`) and save full-suite
|
||||
runs for pre-commit validation; several tests read live shared
|
||||
state (system_settings, real library folders, the periodic backup
|
||||
scan's websocket broadcasts) and flap on data/timing your change
|
||||
never touched. Full recipes: docs/contributing/development.md
|
||||
→ Testing.
|
||||
- **NEVER COMMIT BINARY MEDIA** (mp3/m4b/flac/... fixtures):
|
||||
`internal/services/testdata/` is gitignored by policy; generate
|
||||
media at test runtime with ffmpeg instead (test images ship it).
|
||||
ffmpeg cannot write Audible freeform atoms — those paths are unit-
|
||||
tested with synthetic ffprobeReport values. Recipes:
|
||||
docs/contributing/development.md → Media fixtures.
|
||||
|
||||
## Timezones
|
||||
|
||||
@@ -96,9 +151,11 @@ of a server bug: stop and flag the user.
|
||||
values** — it emits the literal text. `x-init="f('{ col.ID }')"`
|
||||
shipped a request to `/api/collections/{ collection.ID }` found by
|
||||
the request logs; the whole attribute value must BE an expression:
|
||||
`x-init={ "f('" + col.ID + "')" }`. Note
|
||||
`class="theme-{ user.Theme }"` has always been literal (harmlessly
|
||||
masked by the JS theme engine).
|
||||
`x-init={ "f('" + col.ID + "')" }`. (Every occurrence of this bug —
|
||||
including `class="theme-{ user.Theme }"` on 22 page bodies and 26
|
||||
attribute interpolations — was swept and fixed in 2026-10; when
|
||||
adding attributes with dynamic values, the whole value is an
|
||||
expression, and free text goes through `templates.JSONArg`.)
|
||||
2. **SettingsRegistry caches**: writes must go through the settings
|
||||
API (Reload on save). Direct SQL writes to system_settings leave
|
||||
the cache stale until the next API-path write — never "preview"
|
||||
|
||||
+4
-1
@@ -59,7 +59,10 @@ RUN cp -r /go/pkg/mod /app/go-module-cache
|
||||
# This stage is ONLY used for running tests, never deployed to production
|
||||
FROM golang:1.26-alpine AS test-runner
|
||||
|
||||
RUN apk --no-cache add ca-certificates curl poppler-utils
|
||||
# ffmpeg/ffprobe match the runtime stage: the audio ingestion (tag
|
||||
# fallbacks, embedded covers, cue probing) must be exercised in tests
|
||||
# exactly as it runs in production.
|
||||
RUN apk --no-cache add ca-certificates curl poppler-utils ffmpeg
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -47,9 +47,34 @@ help:
|
||||
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
|
||||
|
||||
# Run unit tests locally (fast, no containers)
|
||||
test:
|
||||
test: lint-rendering
|
||||
go test ./internal/... -v -short
|
||||
|
||||
# Rendering-ownership guard (AGENTS.md → Rendering ownership): templ renders
|
||||
# UI markup, JS orchestrates. Fails when a web/src file builds more HTML
|
||||
# template literals than its recorded baseline (web/src/rendering-allowlist.txt),
|
||||
# or when a .templ file gains an inline <script> block (theme_flash_guard.templ
|
||||
# is the only sanctioned one). Shrink baselines as fragments move to templ
|
||||
# partials; grow one only with a written reason.
|
||||
lint-rendering:
|
||||
@violations=""; \
|
||||
for f in $$(grep -rlE 'innerHTML[[:space:]]*=[[:space:]]*' web/src --include='*.ts' | grep -v 'web/src/reader/'); do \
|
||||
count=$$(grep -E 'innerHTML[[:space:]]*=[[:space:]]*' $$f | grep -cvE 'innerHTML[[:space:]]*=[[:space:]]*""[[:space:]]*$$'); \
|
||||
allowed=$$(grep -F "$$f:" web/src/rendering-allowlist.txt | cut -d: -f2); \
|
||||
allowed=$${allowed:-0}; \
|
||||
if [ $$count -gt $$allowed ]; then \
|
||||
violations="$$violations $$f ($$count > $$allowed)"; \
|
||||
fi; \
|
||||
done; \
|
||||
for f in $$(grep -rln '<script>' templates --include='*.templ'); do \
|
||||
case $$f in *theme_flash_guard*) continue;; esac; \
|
||||
violations="$$violations $$f (inline script)"; \
|
||||
done; \
|
||||
if [ -n "$$violations" ]; then \
|
||||
echo "rendering-ownership violations:$$violations"; exit 1; \
|
||||
fi; \
|
||||
echo "rendering ownership OK"
|
||||
|
||||
# Run integration tests in containers (matches production environment)
|
||||
test-integration:
|
||||
@echo "Building test containers..."
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
info:
|
||||
name: Get Audio Chapters
|
||||
type: http
|
||||
seq: 30
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{base_url}}/api/media-items/{{media_item_id}}/chapters"
|
||||
headers:
|
||||
- name: ""
|
||||
value: application/json
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
|
||||
docs: |-
|
||||
## Get Audio Chapters
|
||||
|
||||
The format-abstraction chapter list for audiobooks
|
||||
(media_items.audio_metadata). Single-file books return `layout: ""` and
|
||||
an empty `tracks` array; folder-of-tracks books return `layout:
|
||||
"folder"` and zip books `layout: "zip"`, each with one `tracks` entry
|
||||
per playable file (global-seconds start/end plus the per-track `url`).
|
||||
|
||||
**Method:** GET
|
||||
|
||||
**Endpoint:** /api/media-items/{id}/chapters
|
||||
@@ -0,0 +1,31 @@
|
||||
info:
|
||||
name: Get Audio Track
|
||||
type: http
|
||||
seq: 31
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{base_url}}/api/media-items/{{media_item_id}}/audio/0"
|
||||
headers:
|
||||
- name: Range
|
||||
value: bytes=0-1023
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
|
||||
docs: |-
|
||||
## Get Audio Track
|
||||
|
||||
Streams one track of a multi-file audiobook (folder or zip layout).
|
||||
Range requests are honored: folder tracks are served from the real
|
||||
file; zip tracks are extracted in place (backward seeks re-open the
|
||||
entry). Requires the item's library to be visible to the requester
|
||||
(403 otherwise). Single-file books have no track manifest — 404.
|
||||
|
||||
**Method:** GET
|
||||
|
||||
**Endpoint:** /api/media-items/{id}/audio/{trackIndex}
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The Library Type dropdown on /admin/library (and the setup wizard, which
|
||||
// shares LibraryTypeOptions) must render from the DB-backed library_types
|
||||
// table. Regression: audiobooks shipped backend-complete (constant, allowed
|
||||
// extensions, MIME map, schema seed row, /api/libraries/types) while the
|
||||
// dropdowns hardcoded the original three types, so an audiobook library
|
||||
// could not be created from the UI.
|
||||
func TestAdminLibraryPageDropdownCoversAllLibraryTypes(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
setup := setupTestServer(t)
|
||||
|
||||
code, html := ssrGet(t, setup.Server.URL+"/admin/library", setup.Token)
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
|
||||
for _, typ := range []string{"ebooks", "comics", "manga", "audiobooks"} {
|
||||
assert.Contains(t, html, `value="`+typ+`"`,
|
||||
"library type %q missing from the create-library dropdown", typ)
|
||||
}
|
||||
assert.Contains(t, html, "Audiobooks")
|
||||
|
||||
// Options come from the DB loop, not the deleted hardcode: exactly one
|
||||
// audiobooks option per page.
|
||||
assert.Equal(t, 1, strings.Count(html, `value="audiobooks"`))
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
package main
|
||||
|
||||
// Multi-file audiobook integration tests: folder-of-tracks and zip-of-
|
||||
// audio books must ingest as ONE item each (never per-track items), the
|
||||
// /chapters manifest must expose the track list, the per-track streaming
|
||||
// endpoint must serve bytes behind the library-visibility gate, and
|
||||
// rescans must be idempotent while absorbing pre-grouping one-track
|
||||
// strays. Live dev DB — all fixtures use test-named libraries under
|
||||
// t.TempDir() folders and cascade away with the library delete.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// writeTestTrack renders a short silent mp3 via ffmpeg when available so
|
||||
// ffprobe can produce real durations; otherwise falls back to dummy bytes
|
||||
// (ingest is ffprobe-optional by design — structure assertions don't need
|
||||
// real audio).
|
||||
func writeTestTrack(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
|
||||
"-i", "sine=frequency=440:duration=0.3", "-codec:a", "libmp3lame", "-b:a", "32k", path)
|
||||
if out, err := cmd.CombinedOutput(); err == nil {
|
||||
return
|
||||
} else {
|
||||
t.Logf("ffmpeg track render failed (%s), falling back to dummy bytes: %s", err, out)
|
||||
}
|
||||
}
|
||||
require.NoError(t, os.WriteFile(path, []byte("ID3 dummy audio"), 0o644))
|
||||
}
|
||||
|
||||
// audioTestLibrary builds an audiobooks-type library with a fresh temp
|
||||
// folder, returning its UUID and the folder path.
|
||||
func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) {
|
||||
t.Helper()
|
||||
libID, folder := createTypedTestLibrary(t, setup, name, "audiobooks")
|
||||
return libID, folder
|
||||
}
|
||||
|
||||
// listLibraryAudioItems returns every media item row of the library.
|
||||
func listLibraryAudioItems(t *testing.T, setup *TestServerSetup, libID pgtype.UUID) []database.ListMediaItemsByLibraryIncludingArchivedRow {
|
||||
t.Helper()
|
||||
items, err := setup.DB.ListMediaItemsByLibraryIncludingArchived(context.Background(), libID)
|
||||
require.NoError(t, err)
|
||||
return items
|
||||
}
|
||||
|
||||
// findAudioItemByPath returns the item with the given file_path.
|
||||
func findAudioItemByPath(t *testing.T, setup *TestServerSetup, libID pgtype.UUID, filePath string) (database.ListMediaItemsByLibraryIncludingArchivedRow, bool) {
|
||||
t.Helper()
|
||||
for _, item := range listLibraryAudioItems(t, setup, libID) {
|
||||
if item.FilePath == filePath {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return database.ListMediaItemsByLibraryIncludingArchivedRow{}, false
|
||||
}
|
||||
|
||||
// decodeAudioPayload parses the audio_metadata JSONB of an item.
|
||||
func decodeAudioPayload(t *testing.T, item database.ListMediaItemsByLibraryIncludingArchivedRow) (layout string, trackCount, chapterCount int, duration float64) {
|
||||
t.Helper()
|
||||
require.NotNil(t, item.AudioMetadata, "audio_metadata must be present")
|
||||
var payload struct {
|
||||
Layout string `json:"layout"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
Tracks []struct {
|
||||
Index int `json:"index"`
|
||||
Path string `json:"path"`
|
||||
} `json:"tracks"`
|
||||
Chapters []json.RawMessage `json:"chapters"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(item.AudioMetadata, &payload))
|
||||
return payload.Layout, len(payload.Tracks), len(payload.Chapters), payload.DurationSeconds
|
||||
}
|
||||
|
||||
func TestAudiobookFolderOfTracks(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Folder %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// Author/Title/01.mp3, 02.mp3 — the canonical folder book.
|
||||
writeTestTrack(t, filepath.Join(folder, "Test Author", "Test Title", "02 Second.mp3"))
|
||||
writeTestTrack(t, filepath.Join(folder, "Test Author", "Test Title", "01 First.mp3"))
|
||||
// A lone single-file book in a sibling folder must stay single-file.
|
||||
writeTestTrack(t, filepath.Join(folder, "Test Author", "Lone Book", "lonely.mp3"))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
// The folder is ONE item whose file_path is the directory.
|
||||
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Test Author/Test Title"))
|
||||
require.True(t, found, "folder book item must exist at the directory path")
|
||||
layout, tracks, chapters, duration := decodeAudioPayload(t, book)
|
||||
require.Equal(t, "folder", layout)
|
||||
require.Equal(t, 2, tracks)
|
||||
require.Equal(t, 2, chapters)
|
||||
if duration <= 0 {
|
||||
t.Log("no ffprobe durations available in this environment (degraded ingest)")
|
||||
}
|
||||
|
||||
// The lone mp3 keeps the single-file model.
|
||||
_, loneFound := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Test Author/Lone Book/lonely.mp3"))
|
||||
require.True(t, loneFound, "lone mp3 must stay its own single-file item")
|
||||
require.Len(t, listLibraryAudioItems(t, setup, libID), 2, "exactly two items: one folder book + one single-file book")
|
||||
|
||||
// Rescan idempotency: nothing duplicates, manifest unchanged.
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
require.Len(t, listLibraryAudioItems(t, setup, libID), 2)
|
||||
again, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Test Author/Test Title"))
|
||||
require.True(t, found)
|
||||
require.JSONEq(t, string(book.AudioMetadata), string(again.AudioMetadata))
|
||||
}
|
||||
|
||||
func TestAudiobookAbsorbsOneTrackStrays(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Merge %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// First scan: a single mp3 → single-file item (the pre-grouping model).
|
||||
writeTestTrack(t, filepath.Join(folder, "Merge Book", "01 only.mp3"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
_, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Merge Book/01 only.mp3"))
|
||||
require.True(t, found, "single track ingests as its own item first")
|
||||
|
||||
// Second track arrives; rescan must create the folder book and delete
|
||||
// the stray one-track item.
|
||||
writeTestTrack(t, filepath.Join(folder, "Merge Book", "02 and.mp3"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Merge Book"))
|
||||
require.True(t, found, "folder book must exist after rescan")
|
||||
layout, tracks, _, _ := decodeAudioPayload(t, book)
|
||||
require.Equal(t, "folder", layout)
|
||||
require.Equal(t, 2, tracks)
|
||||
|
||||
items := listLibraryAudioItems(t, setup, libID)
|
||||
require.Len(t, items, 1, "the one-track stray must be absorbed into the folder book")
|
||||
}
|
||||
|
||||
func TestAudiobookZipBook(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Zip %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// Build a zip of mp3s via ffmpeg-rendered tracks.
|
||||
dir := t.TempDir()
|
||||
writeTestTrack(t, filepath.Join(dir, "a.mp3"))
|
||||
writeTestTrack(t, filepath.Join(dir, "b.mp3"))
|
||||
zipPath := filepath.Join(folder, "Zipped Book.zip")
|
||||
zipFiles(t, zipPath, map[string]string{
|
||||
"01 first.mp3": filepath.Join(dir, "a.mp3"),
|
||||
"02 second.mp3": filepath.Join(dir, "b.mp3"),
|
||||
})
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Zipped Book.zip"))
|
||||
require.True(t, found, "zip book item must exist at the zip path")
|
||||
layout, tracks, chapters, _ := decodeAudioPayload(t, book)
|
||||
require.Equal(t, "zip", layout)
|
||||
require.Equal(t, 2, tracks)
|
||||
require.Equal(t, 2, chapters)
|
||||
|
||||
// Rescan idempotency.
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
require.Len(t, listLibraryAudioItems(t, setup, libID), 1)
|
||||
}
|
||||
|
||||
func TestAudiobookTrackEndpoints(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
regularID := getRegularUserID(t, setup.DB)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Serve %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
writeTestTrack(t, filepath.Join(folder, "Serve Book", "01 one.mp3"))
|
||||
writeTestTrack(t, filepath.Join(folder, "Serve Book", "02 two.mp3"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Serve Book"))
|
||||
require.True(t, found)
|
||||
bookID := uuid.UUID(book.ID.Bytes)
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("chapters manifest exposes tracks", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/chapters", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var data struct {
|
||||
Layout string `json:"layout"`
|
||||
Tracks []struct {
|
||||
Index int `json:"index"`
|
||||
URL string `json:"url"`
|
||||
Path string `json:"path"`
|
||||
Start float64 `json:"start"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
|
||||
require.Equal(t, "folder", data.Layout)
|
||||
require.Len(t, data.Tracks, 2)
|
||||
require.Equal(t, "/api/media-items/"+bookID.String()+"/audio/0", data.Tracks[0].URL)
|
||||
})
|
||||
|
||||
t.Run("track streams with range support", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio/0", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
req.Header.Set("Range", "bytes=0-99")
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusPartialContent, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("out-of-range track index is 404", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio/9", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("hidden library is 403 on chapters and tracks", func(t *testing.T) {
|
||||
_, err := setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{
|
||||
UserID: pgtype.UUID{Bytes: regularID, Valid: true},
|
||||
LibraryID: libID,
|
||||
IsVisible: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/media-items/" + bookID.String() + "/chapters",
|
||||
"/api/media-items/" + bookID.String() + "/audio/0",
|
||||
} {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode, "%s must be gated", path)
|
||||
}
|
||||
|
||||
// Restore visibility so harness cleanup ordering never sees a
|
||||
// stale hidden row for a deleted library's user.
|
||||
_, err = setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{
|
||||
UserID: pgtype.UUID{Bytes: regularID, Valid: true},
|
||||
LibraryID: libID,
|
||||
IsVisible: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// zipFiles builds a zip archive at zipPath mapping entry names to source
|
||||
// file paths on disk.
|
||||
func zipFiles(t *testing.T, zipPath string, entries map[string]string) {
|
||||
t.Helper()
|
||||
out, err := os.Create(zipPath)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = out.Close() }()
|
||||
w := zip.NewWriter(out)
|
||||
for name, src := range entries {
|
||||
data, err := os.ReadFile(src)
|
||||
require.NoError(t, err)
|
||||
f, err := w.Create(name)
|
||||
require.NoError(t, err)
|
||||
_, err = f.Write(data)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, w.Close())
|
||||
}
|
||||
|
||||
// fixtureAudio copies a committed binary fixture (real tagged media —
|
||||
// the test container has no way to generate it) into a library folder.
|
||||
// Path is relative from cmd/server/tests (three deep) to the services
|
||||
// testdata tree.
|
||||
func fixtureAudio(t *testing.T, name, dest string) {
|
||||
t.Helper()
|
||||
src := filepath.Join("..", "..", "..", "internal", "services", "testdata", "audio", name)
|
||||
data, err := os.ReadFile(src)
|
||||
require.NoError(t, err, "fixture %s must exist in the repo", name)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755))
|
||||
require.NoError(t, os.WriteFile(dest, data, 0o644))
|
||||
}
|
||||
|
||||
// getJSONWithAuth fetches an API endpoint with the admin token and
|
||||
// decodes the body into dst.
|
||||
func getJSONWithAuth(t *testing.T, setup *TestServerSetup, path string, dst interface{}) int {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if dst != nil {
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(dst))
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func TestAudiobookNewContainerFormats(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Formats %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
for _, name := range []string{"book.opus", "book.ogg", "book.oga", "book.aac", "book.wav"} {
|
||||
// one book per folder — a directory with ≥2 audio files is ONE
|
||||
// book by the grouping doctrine
|
||||
fixtureAudio(t, name, filepath.Join(folder, "Formats "+name, name))
|
||||
}
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
items := listLibraryAudioItems(t, setup, libID)
|
||||
require.Len(t, items, 5, "each new container ingests as its own single-file book")
|
||||
mimes := map[string]string{}
|
||||
for _, item := range items {
|
||||
require.Equal(t, "audiobook", item.FormatGroup, "%s must classify as audiobook", item.FilePath)
|
||||
mimes[filepath.Ext(item.FilePath)] = item.MimeType.String
|
||||
}
|
||||
require.Equal(t, map[string]string{
|
||||
".opus": "audio/ogg", ".ogg": "audio/ogg", ".oga": "audio/ogg",
|
||||
".aac": "audio/aac", ".wav": "audio/wav",
|
||||
}, mimes)
|
||||
|
||||
// The download endpoint must serve the REAL content types — a wrong
|
||||
// audio/mpeg fallback makes browsers refuse the stream.
|
||||
for _, name := range []string{"book.opus", "book.aac", "book.wav"} {
|
||||
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Formats "+name+"/"+name))
|
||||
require.True(t, found, name)
|
||||
bookID := uuid.UUID(item.ID.Bytes)
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/download", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
// Drain but discard: a 0.3s sine is a few KB — nothing to save.
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, item.MimeType.String, resp.Header.Get("Content-Type"), "%s content type", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookTagFallbacks(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Tags %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// No sidecars anywhere: the file's own tags are the only source.
|
||||
fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Tag Author", "The Tagged Album", "tagged.mp3"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Tag Author/The Tagged Album/tagged.mp3"))
|
||||
require.True(t, found)
|
||||
require.Equal(t, "The Tagged Album", item.Title, "album tag names the book")
|
||||
require.Equal(t, "Tag Author", item.Author.String, "album_artist tag names the author")
|
||||
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")
|
||||
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,
|
||||
// which ffmpeg cannot write into a fixture — those fallbacks are
|
||||
// pinned by TestApplyAudioTagFallbacks (synthetic reports).
|
||||
}
|
||||
|
||||
func TestAudiobookCueSheetChapters(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Cue %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
fixtureAudio(t, "cuebook.flac", filepath.Join(folder, "Cue Book", "cuebook.flac"))
|
||||
fixtureAudio(t, "cuebook.cue", filepath.Join(folder, "Cue Book", "cuebook.cue"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Cue Book/cuebook.flac"))
|
||||
require.True(t, found)
|
||||
bookID := uuid.UUID(item.ID.Bytes)
|
||||
|
||||
var data struct {
|
||||
Chapters []struct {
|
||||
Index int `json:"index"`
|
||||
Title string `json:"title"`
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
} `json:"chapters"`
|
||||
}
|
||||
status := getJSONWithAuth(t, setup, "/api/media-items/"+bookID.String()+"/chapters", &data)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Len(t, data.Chapters, 3, "cue tracks become chapters")
|
||||
require.Equal(t, "Opening", data.Chapters[0].Title)
|
||||
require.Equal(t, "The Middle Bit", data.Chapters[1].Title)
|
||||
require.InDelta(t, 20.0, data.Chapters[1].Start, 0.01)
|
||||
require.Equal(t, "Closing", data.Chapters[2].Title)
|
||||
}
|
||||
|
||||
func TestAudiobookEmbeddedM4BChapters(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio M4B %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
fixtureAudio(t, "chaptered.m4b", filepath.Join(folder, "Chaptered Book.m4b"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Chaptered Book.m4b"))
|
||||
require.True(t, found)
|
||||
bookID := uuid.UUID(item.ID.Bytes)
|
||||
|
||||
var data struct {
|
||||
Chapters []struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"chapters"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
}
|
||||
status := getJSONWithAuth(t, setup, "/api/media-items/"+bookID.String()+"/chapters", &data)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Len(t, data.Chapters, 2, "embedded m4b chapters parse")
|
||||
require.Equal(t, "Part One", data.Chapters[0].Title)
|
||||
require.Equal(t, "Part Two", data.Chapters[1].Title)
|
||||
require.Greater(t, data.DurationSeconds, float64(0))
|
||||
}
|
||||
|
||||
func TestAudiobookPlaylistOrdering(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio M3U %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// Three tag-less tracks (natural order 01,02,10) plus a playlist
|
||||
// imposing a DIFFERENT order and names.
|
||||
fixtureAudio(t, "plain.mp3", filepath.Join(folder, "Playlist Book", "01_one.mp3"))
|
||||
fixtureAudio(t, "plain.mp3", filepath.Join(folder, "Playlist Book", "02_two.mp3"))
|
||||
fixtureAudio(t, "plain.mp3", filepath.Join(folder, "Playlist Book", "10_ten.mp3"))
|
||||
playlist := "#EXTM3U\n#EXTINF:60,Epilogue\n./10_ten.mp3\n#EXTINF:60,Prologue\n01_one.mp3\n"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(folder, "Playlist Book", "book.m3u"), []byte(playlist), 0o644))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Playlist Book"))
|
||||
require.True(t, found, "folder book created")
|
||||
require.NotNil(t, item.AudioMetadata)
|
||||
var payload struct {
|
||||
Tracks []struct {
|
||||
Index int `json:"index"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(item.AudioMetadata, &payload))
|
||||
require.Len(t, payload.Tracks, 3)
|
||||
require.Equal(t, "Playlist Book/10_ten.mp3", payload.Tracks[0].Path, "playlist order beats natural sort")
|
||||
require.Equal(t, "Epilogue", payload.Tracks[0].Title)
|
||||
require.Equal(t, "Playlist Book/01_one.mp3", payload.Tracks[1].Path)
|
||||
require.Equal(t, "Prologue", payload.Tracks[1].Title)
|
||||
require.Equal(t, "Playlist Book/02_two.mp3", payload.Tracks[2].Path, "unlisted track appended in natural order")
|
||||
}
|
||||
|
||||
func TestAudiobookEmbeddedCoverArt(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Cover %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// Single file: embedded APIC → "<file>.cover.jpg" sibling.
|
||||
fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Cover Book", "tagged.mp3"))
|
||||
// Folder book: sidecar cover.jpg must WIN over the embedded art.
|
||||
fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Sidecar Book", "01 track.mp3"))
|
||||
fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Sidecar Book", "02 track.mp3"))
|
||||
// A real (if tiny) sidecar cover next to the folder book:
|
||||
fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Sidecar Book", "cover.jpg"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
single, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Cover Book/tagged.mp3"))
|
||||
require.True(t, found)
|
||||
if !ffmpegAvailableForTest() {
|
||||
t.Skip("ffmpeg unavailable in this environment; embedded covers degrade gracefully")
|
||||
}
|
||||
require.NotEmpty(t, single.CoverImagePath.String, "embedded APIC becomes the cover")
|
||||
coverRel := single.CoverImagePath.String
|
||||
_, err := os.Stat(filepath.Join(folder, coverRel))
|
||||
require.NoError(t, err, "cover sibling file exists on disk")
|
||||
|
||||
folder_, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Sidecar Book"))
|
||||
require.True(t, found)
|
||||
require.Equal(t, filepath.ToSlash("Sidecar Book/cover.jpg"), folder_.CoverImagePath.String,
|
||||
"sidecar cover wins over embedded art")
|
||||
}
|
||||
|
||||
func ffmpegAvailableForTest() bool {
|
||||
_, err := os.Stat("/usr/bin/ffmpeg")
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
_, err = exec.LookPath("ffmpeg")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func TestRescanKeepsFilenameTitle(t *testing.T) {
|
||||
// Regression pin: extractMetadata's default branch (.mobi/.azw/.pdb
|
||||
// and unknown types) no longer pre-fills the filename as title, so
|
||||
// the rescan path MUST apply the final filename fallback itself —
|
||||
// a sidecar-less, tag-less book at a library root keeps its title
|
||||
// instead of being blanked by a rescan.
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Rescan Title %d", time.Now().UnixNano()), "ebooks")
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// Library-root .mobi with no sidecars, no tags, no folder structure.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(folder, "Plain Book.mobi"), []byte("dummy mobi bytes"), 0o644))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
item, found := findAudioItemByPath(t, setup, libID, "Plain Book.mobi")
|
||||
require.True(t, found)
|
||||
require.Equal(t, "Plain Book", item.Title, "create path falls back to filename")
|
||||
|
||||
bookID := uuid.UUID(item.ID.Bytes)
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items/"+bookID.String()+"/rescan", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
rescanned, err := setup.DB.GetMediaItem(context.Background(), item.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Plain Book", rescanned.Title, "rescan must not blank the filename-derived title")
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
// POST /api/scanner/cancel/:jobId is admin-only and must answer with
|
||||
// distinct codes for unknown (404) and already-terminal (409) jobs.
|
||||
func TestScanCancelEndpointSemantics(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("cancel without admin role is forbidden", func(t *testing.T) {
|
||||
url := fmt.Sprintf("%s/api/scanner/cancel/%s", setup.Server.URL, uuid.New().String())
|
||||
req, _ := http.NewRequest("POST", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("cancel unknown job is not found", func(t *testing.T) {
|
||||
url := fmt.Sprintf("%s/api/scanner/cancel/%s", setup.Server.URL, uuid.New().String())
|
||||
req, _ := http.NewRequest("POST", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// Cancelling a manual scan mid-run must never corrupt library state:
|
||||
// the abort skips the archive pass, so unvisited (but on-disk) files
|
||||
// are not treated as missing. The canary item points at a file that is
|
||||
// lexically LAST in the folder, so a mid-walk abort that (incorrectly)
|
||||
// ran the archive pass would mark it missing and fail the assertion.
|
||||
// The assertions hold under every interleaving — whether the cancel
|
||||
// lands mid-scan or the scan already finished — so the test cannot
|
||||
// flap against timing.
|
||||
func TestScanCancelMidScanSafety(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
token := setup.Token
|
||||
|
||||
// Test library (helper registers API-delete cleanup so the run
|
||||
// leaves no residue in the dev database).
|
||||
libraryID := createTestLibraryWithFolder(t, setup.Server, token, "Test Scan Cancel Library", false)
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Folder on disk: many junk .epub files (zero bytes cannot parse as
|
||||
// a zip/epub, so processMediaFile rejects each one and creates no
|
||||
// rows) plus the canary file, lexically last.
|
||||
dir := t.TempDir()
|
||||
const fileCount = 1200
|
||||
junk := bytes.Repeat([]byte{0}, 1024)
|
||||
for i := 0; i < fileCount; i++ {
|
||||
name := filepath.Join(dir, fmt.Sprintf("book-%04d.epub", i))
|
||||
require.NoError(t, os.WriteFile(name, junk, 0o644))
|
||||
}
|
||||
canaryRel := "zzz-last.epub"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, canaryRel), junk, 0o644))
|
||||
|
||||
// Register the folder with the library.
|
||||
folderURL := fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID)
|
||||
body, _ := json.Marshal(map[string]interface{}{"folder_path": dir})
|
||||
req, _ := http.NewRequest("POST", folderURL, bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, "folder creation should succeed")
|
||||
|
||||
// Pre-existing item whose file exists on disk. A normal completed
|
||||
// scan never marks it missing; neither may a cancelled one.
|
||||
_, err = setup.DBPool.Exec(context.Background(),
|
||||
"INSERT INTO media_items (library_id, title, file_path) VALUES ($1, $2, $3)",
|
||||
pgtype.UUID{Bytes: libUUID, Valid: true}, "Test Cancel Canary", canaryRel)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Start the scan and cancel it as soon as it is accepted.
|
||||
jobID := startScan(t, client, setup.Server.URL, token, libraryID)
|
||||
|
||||
cancelURL := fmt.Sprintf("%s/api/scanner/cancel/%s", setup.Server.URL, jobID)
|
||||
cancelReq, _ := http.NewRequest("POST", cancelURL, nil)
|
||||
cancelReq.Header.Set("Authorization", "Bearer "+token)
|
||||
cancelResp, err := client.Do(cancelReq)
|
||||
require.NoError(t, err)
|
||||
_ = cancelResp.Body.Close()
|
||||
// 200 = cancelled mid-run; 409 = the scan already finished before
|
||||
// the cancel landed. Both are acceptable; the assertions below hold
|
||||
// either way.
|
||||
assert.Contains(t, []int{http.StatusOK, http.StatusConflict}, cancelResp.StatusCode)
|
||||
|
||||
// Poll to a terminal state.
|
||||
final := pollJobToTerminal(t, client, setup.Server.URL, token, jobID)
|
||||
assert.Contains(t, []string{"completed", "cancelled"}, final["status"],
|
||||
"scan must finish cleanly or be cancelled, got %v (error: %v)", final["status"], final["error"])
|
||||
|
||||
// The safety invariant: no item was marked missing or archived, and
|
||||
// the junk files created no phantom rows.
|
||||
var items, missingSum, archived int
|
||||
err = setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT COUNT(*),
|
||||
COALESCE(SUM(missing_scan_count), 0),
|
||||
COUNT(*) FILTER (WHERE archived_at IS NOT NULL)
|
||||
FROM media_items WHERE library_id = $1`,
|
||||
pgtype.UUID{Bytes: libUUID, Valid: true}).Scan(&items, &missingSum, &archived)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, items, "junk files must not create media items")
|
||||
assert.Zero(t, missingSum, "cancelled/completed scan must not mark on-disk files missing")
|
||||
assert.Zero(t, archived, "no item may be archived by this scan")
|
||||
|
||||
// Cancelling a terminal job is a conflict, whichever terminal state
|
||||
// the job reached.
|
||||
cancelReq2, _ := http.NewRequest("POST", cancelURL, nil)
|
||||
cancelReq2.Header.Set("Authorization", "Bearer "+token)
|
||||
cancelResp2, err := client.Do(cancelReq2)
|
||||
require.NoError(t, err)
|
||||
_ = cancelResp2.Body.Close()
|
||||
assert.Equal(t, http.StatusConflict, cancelResp2.StatusCode)
|
||||
}
|
||||
|
||||
func startScan(t *testing.T, client *http.Client, baseURL, token, libraryID string) string {
|
||||
url := fmt.Sprintf("%s/api/libraries/%s/scan", baseURL, libraryID)
|
||||
req, _ := http.NewRequest("POST", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||
|
||||
var decoded map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
|
||||
jobID, ok := decoded["job_id"].(string)
|
||||
require.True(t, ok, "job_id should be a string")
|
||||
require.NotEmpty(t, jobID)
|
||||
return jobID
|
||||
}
|
||||
|
||||
func pollJobToTerminal(t *testing.T, client *http.Client, baseURL, token, jobID string) map[string]interface{} {
|
||||
url := fmt.Sprintf("%s/api/scanner/status/%s", baseURL, jobID)
|
||||
for i := 0; i < 30; i++ {
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
var status map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&status)
|
||||
_ = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
switch status["status"] {
|
||||
case "completed", "failed", "cancelled":
|
||||
return status
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("job %s did not reach a terminal state within 15s", jobID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSeriesPagination pins the SSR series-page pagination contract
|
||||
// (2026-10-19 paginator fix). The /series page renders its grid and
|
||||
// paginator entirely server-side (templates/series.templ); page state
|
||||
// lives in the ?page= query param. The fixture mirrors a prod bulk
|
||||
// import: 30 series whose items share ONE identical created_at (a single
|
||||
// transaction's NOW()), which historically left the ORDER BY tied — the
|
||||
// tiebreaker (library_id, series) makes pages deterministic.
|
||||
func TestSeriesPagination(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
setup := setupTestServer(t)
|
||||
|
||||
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Pagination Library", false)
|
||||
|
||||
ctx := context.Background()
|
||||
const totalSeries = 30
|
||||
for i := 1; i <= totalSeries; i++ {
|
||||
_, err := setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO media_items (library_id, title, file_path, series, created_at, imported_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, '2026-01-01 00:00:00+00', '2026-01-01 00:00:00+00', '2026-01-01 00:00:00+00')`,
|
||||
libID, fmt.Sprintf("Pagination Probe Book %02d", i),
|
||||
fmt.Sprintf("/tmp/test-pagination/book-%02d.epub", i),
|
||||
fmt.Sprintf("Pagination Probe Series %02d", i),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID)
|
||||
})
|
||||
|
||||
// templ separates dynamic text segments with HTML comments; strip them
|
||||
// so plain regexes work on names and the page chip.
|
||||
stripComments := func(html string) string {
|
||||
return regexp.MustCompile(`<!--.*?-->`).ReplaceAllString(html, "")
|
||||
}
|
||||
seriesNames := func(html string) map[string]bool {
|
||||
names := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`<h3[^>]*>([^<]+)</h3>`).FindAllStringSubmatch(stripComments(html), -1) {
|
||||
names[strings.TrimSpace(m[1])] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
pageChip := func(html string) string {
|
||||
m := regexp.MustCompile(`Page\s+(\d+)\s+of\s+(\d+)`).FindStringSubmatch(stripComments(html))
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("Page %s of %s", m[1], m[2])
|
||||
}
|
||||
|
||||
perPage := 24 // internal/router/frontend.go perSeriesPage
|
||||
|
||||
t.Run("page1 defaults and next link", func(t *testing.T) {
|
||||
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID, setup.Token)
|
||||
require.Equal(t, 200, code)
|
||||
|
||||
assert.Equal(t, fmt.Sprintf("Page 1 of 2"), pageChip(body))
|
||||
names := seriesNames(body)
|
||||
assert.Len(t, names, perPage, "page 1 renders exactly 24 cards")
|
||||
assert.Contains(t, names, "Pagination Probe Series 01", "deterministic order: page 1 starts at the first series")
|
||||
assert.Contains(t, names, fmt.Sprintf("Pagination Probe Series %02d", perPage), "page 1 ends at the 24th series")
|
||||
assert.NotContains(t, names, fmt.Sprintf("Pagination Probe Series %02d", perPage+1), "page 1 must not leak page-2 rows")
|
||||
|
||||
assert.NotContains(t, body, "Previous", "no Previous link on page 1")
|
||||
// templ HTML-escapes & as & inside attribute values.
|
||||
assert.Contains(t, body, fmt.Sprintf(`href="/series?library_id=%s&page=2"`, libID), "Next link carries library_id and page")
|
||||
})
|
||||
|
||||
t.Run("page2 is disjoint and terminal", func(t *testing.T) {
|
||||
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+"&page=2", setup.Token)
|
||||
require.Equal(t, 200, code)
|
||||
|
||||
assert.Equal(t, "Page 2 of 2", pageChip(body))
|
||||
names := seriesNames(body)
|
||||
assert.Len(t, names, totalSeries-perPage, "page 2 renders the remainder")
|
||||
|
||||
code1, body1 := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+"&page=1", setup.Token)
|
||||
require.Equal(t, 200, code1)
|
||||
page1 := seriesNames(body1)
|
||||
|
||||
for name := range names {
|
||||
assert.NotContains(t, page1, name, "pages must be disjoint even with fully tied created_at")
|
||||
}
|
||||
assert.NotContains(t, body, "Next", "no Next link on the last page")
|
||||
assert.Contains(t, body, fmt.Sprintf(`href="/series?library_id=%s&page=1"`, libID), "Previous link carries library_id and page")
|
||||
})
|
||||
|
||||
t.Run("missing page param defaults to page 1", func(t *testing.T) {
|
||||
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID, setup.Token)
|
||||
require.Equal(t, 200, code)
|
||||
assert.Equal(t, "Page 1 of 2", pageChip(body))
|
||||
})
|
||||
|
||||
t.Run("invalid page param falls back to page 1", func(t *testing.T) {
|
||||
for _, bad := range []string{"0", "-3", "two"} {
|
||||
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+"&page="+bad, setup.Token)
|
||||
require.Equal(t, 200, code, "page=%s", bad)
|
||||
assert.Equal(t, "Page 1 of 2", pageChip(body), "page=%s must clamp to page 1", bad)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -244,8 +244,12 @@ CREATE TABLE IF NOT EXISTS media_items (
|
||||
-- { "narrators": [], "duration_seconds": N, "codec_profile": "LC",
|
||||
-- "bit_rate": N, "asin": "...", "chapters": [
|
||||
-- { "index": 0, "title": "...", "start": 0.0, "end": 168.0 } ] }
|
||||
-- The chapters array IS the format abstraction: embedded m4b chapters
|
||||
-- and (later) folder-as-book files both write this exact shape.
|
||||
-- The chapters array IS the format abstraction: embedded m4b chapters,
|
||||
-- folder-of-tracks books and zip-of-audio books all write this exact
|
||||
-- shape. Multi-file books add additive keys: "layout"
|
||||
-- ("folder" | "zip"; absent = single file) and "tracks" (one entry
|
||||
-- per playable file with global-seconds start/end plus the path the
|
||||
-- /audio/:trackIndex endpoint resolves).
|
||||
audio_metadata JSONB
|
||||
);
|
||||
|
||||
@@ -263,6 +267,10 @@ ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS narrators TEXT[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS narrators_search TEXT[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS abridged BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
-- audio_metadata is declared in the CREATE TABLE above, which only fresh
|
||||
-- installs ever see — this ALTER is the upgrade path for pre-A2 installs
|
||||
-- (its absence 42703'd every scan and archive pass on upgraded deployments).
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS audio_metadata JSONB;
|
||||
|
||||
-- Per-field user-override tracking (idempotent backfill for existing
|
||||
-- installs; the column is also declared in the media_items table
|
||||
@@ -371,6 +379,11 @@ CREATE TABLE IF NOT EXISTS reading_progress (
|
||||
conflict_resolved BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- audio_offset_seconds upgrade path: declared in the CREATE TABLE above
|
||||
-- (Batch A) with no ALTER, so pre-A installs lacked the column and every
|
||||
-- progress save — the shared upsert names it — failed with SQLSTATE 42703.
|
||||
ALTER TABLE reading_progress ADD COLUMN IF NOT EXISTS audio_offset_seconds INTEGER CHECK (audio_offset_seconds >= 0);
|
||||
|
||||
-- Create media_ratings table
|
||||
CREATE TABLE IF NOT EXISTS media_ratings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -1344,6 +1357,8 @@ CREATE TABLE IF NOT EXISTS unlinked_books (
|
||||
|
||||
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.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_unlinked_books_device_content ON unlinked_books(device_id, content_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_unlinked_books_resolved ON unlinked_books(resolved);
|
||||
|
||||
-- Create saved_filters for users to be able to filter different parts of the application
|
||||
|
||||
@@ -102,3 +102,20 @@ cover_image_path, has_conflict, format_group, badge`.
|
||||
origin — its rows are unchanged. Request-parse widening documented per
|
||||
rule 2; response shapes untouched; locked by
|
||||
TestOriginWriterAttribution.
|
||||
|
||||
## Changelog entry — multi-file audiobooks (2026-10-18)
|
||||
|
||||
- `GET /api/media-items/:id/chapters` gained ADDITIVE response keys
|
||||
`layout` ("" for single-file books, `"folder"` or `"zip"` for
|
||||
multi-file books) and `tracks[]` (`index, url, title, path, start,
|
||||
end, duration_seconds, size_bytes`; empty for single-file books).
|
||||
Existing keys (`media_item_id, chapters, duration_seconds,
|
||||
codec_profile`) unchanged — no pin existed on this endpoint, so no
|
||||
pin widened; recorded here per rule 2's spirit.
|
||||
- New sibling endpoint `GET /api/media-items/:id/audio/:trackIndex`
|
||||
streams one track of a multi-file book (Range-capable). New surface,
|
||||
not a modification; gated by library visibility like `ServeFile`
|
||||
(403 on hidden libraries).
|
||||
- `media_items.audio_metadata` payload gained additive JSON keys
|
||||
`layout` and `tracks` (see schema.sql comment). Consumers parse
|
||||
additively; stored single-file payloads are unchanged.
|
||||
@@ -258,17 +258,82 @@ go tool cover -html=coverage.out
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Integration tests hit the LIVE dev database and bind-mounted paths, both
|
||||
of which exist only inside the Docker stack. A bare host `go test
|
||||
./cmd/server/tests` fails with database-auth errors on every machine
|
||||
that isn't running the compose stack — the container is the only
|
||||
supported runner.
|
||||
|
||||
```bash
|
||||
# Run integration tests (may hit rate limits)
|
||||
go test ./cmd/server/tests -v
|
||||
# Run the whole integration suite (pre-commit validation — NOT for
|
||||
# iterating on a change; some tests are coupled to the shared dev DB's
|
||||
# live state and this environment)
|
||||
make test-integration
|
||||
|
||||
# Run with test mode (recommended)
|
||||
TEST_MODE=true RATE_LIMIT_ENABLED=false go test ./cmd/server/tests -v
|
||||
|
||||
# Run specific test
|
||||
go test ./cmd/server/tests -run TestAuth -v
|
||||
# Iterate on YOUR change: run only your own tests, in the container
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile tests run --rm tests \
|
||||
go test ./cmd/server/tests -run 'TestYourPrefix' -v -timeout 600s
|
||||
```
|
||||
|
||||
**Run only your own tests while iterating.** A full-suite run rebuilds
|
||||
the test image and takes minutes; more importantly, several tests read
|
||||
live state (the dev DB's `system_settings`, real library folders, a
|
||||
periodic backup scan broadcasting over websocket) and can fail on
|
||||
timing or on data your change never touched. Filtered runs are cheap,
|
||||
deterministic for your scope, and don't fight the live reader for the
|
||||
database.
|
||||
|
||||
Test-created data rules (harness cleanup depends on them): users use
|
||||
`@tests.bookhoard.internal` / `@example.com` emails, libraries and
|
||||
collections carry "test"-prefixed names, and cleanups delete test
|
||||
libraries (cascades handle the rest) rather than table-wide deletes.
|
||||
|
||||
### Media Fixtures (audio/books)
|
||||
|
||||
**Never commit binary media.** The Go toolchain and Docker prod builds
|
||||
ignore `internal/services/testdata/` entirely (gitignored by policy —
|
||||
see `.gitignore`), and the production image never copies `internal/`.
|
||||
Generate what a test needs at runtime with ffmpeg (shipped in both the
|
||||
test-runner and runtime images):
|
||||
|
||||
```bash
|
||||
# The five audio containers (audiobook library accepts all of these)
|
||||
ffmpeg -f lavfi -i sine=frequency=440:duration=0.3 -codec:a libmp3lame -b:a 32k plain.mp3
|
||||
ffmpeg -f lavfi -i sine=frequency=440:duration=0.3 -codec:a flac book.flac
|
||||
ffmpeg -f lavfi -i sine=frequency=440:duration=0.3 -codec:a libopus -b:a 24k book.opus
|
||||
ffmpeg -f lavfi -i sine=frequency=440:duration=0.3 -codec:a libvorbis -q:a 1 book.ogg
|
||||
ffmpeg -f lavfi -i sine=frequency=440:duration=0.3 -codec:a aac -b:a 32k book.aac
|
||||
ffmpeg -f lavfi -i sine=frequency=440:duration=0.3 -codec:a pcm_s16le book.wav
|
||||
|
||||
# Tags (album=book title, album_artist=author, composer=narrator —
|
||||
# Audible's own convention — plus disc/track ordering keys)
|
||||
ffmpeg -i in.mp3 -codec copy \
|
||||
-metadata title="Chapter Three" -metadata album="The Book" \
|
||||
-metadata album_artist="Author" -metadata composer="Narrator" \
|
||||
-metadata track="3/9" -metadata disc="2/3" \
|
||||
-metadata grouping="Series" -metadata genre="Audiobook" -metadata date=2020 out.mp3
|
||||
|
||||
# Embedded cover art (mp3 APIC / m4b covr): the picture rides as the
|
||||
# only video stream
|
||||
ffmpeg -i cover.jpg -i audio-only.mp3 -map 1:a -map 0:v -c:v copy \
|
||||
-disposition:v attached_pic tagged.mp3
|
||||
|
||||
# Embedded m4b chapters: an ffmetadata sidecar file
|
||||
printf ';FFMETADATA1\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=500\ntitle=Part One\n' > chapters.txt
|
||||
ffmpeg -i in.m4b -i chapters.txt -map 0:a -map 1 -map_metadata 1 -codec copy -f ipod chaptered.m4b
|
||||
```
|
||||
|
||||
CUE sheets and m3u playlists are plain text — write them inline in the
|
||||
test. **The one thing ffmpeg cannot synthesize: Audible freeform atoms**
|
||||
(`----:com.apple.iTunes:SERIES`, `:SUBTITLE`, `:ASIN`, `:ABRIDGED`) —
|
||||
those ingestion paths are covered by unit tests using synthetic
|
||||
`ffprobeReport` values instead of binary files.
|
||||
|
||||
Manual end-to-end testing against the live system: drop fixture books
|
||||
into `uploads/Audiobooks/Ingestion Fixtures/` — **one book per
|
||||
subfolder** (a directory with ≥2 audio files ingests as ONE
|
||||
folder-of-tracks book) — and run a library scan from the admin UI.
|
||||
|
||||
### API Testing with Bruno OpenCollection YAML
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Get Audio Chapters
|
||||
|
||||
The format-abstraction chapter list for audiobooks, sourced from
|
||||
`media_items.audio_metadata`. Embedded m4b chapters, folder-of-tracks
|
||||
books and zip-of-audio books all write the same chapter shape; the
|
||||
locator `{chapter, audio_offset_seconds}` and this endpoint never learn
|
||||
which file model produced them.
|
||||
|
||||
**Endpoint**: `GET /api/media-items/{id}/chapters`
|
||||
**Auth**: Required (JWT)
|
||||
|
||||
## Response
|
||||
|
||||
```json
|
||||
{
|
||||
"media_item_id": "…",
|
||||
"chapters": [
|
||||
{ "index": 0, "title": "Chapter One", "start": 0.0, "end": 168.0 }
|
||||
],
|
||||
"duration_seconds": 36000.0,
|
||||
"codec_profile": "mp3",
|
||||
"layout": "folder",
|
||||
"tracks": [
|
||||
{
|
||||
"index": 0,
|
||||
"url": "/api/media-items/{id}/audio/0",
|
||||
"title": "01 First",
|
||||
"path": "Author/Title/01 First.mp3",
|
||||
"start": 0.0,
|
||||
"end": 168.0,
|
||||
"duration_seconds": 168.0,
|
||||
"size_bytes": 2700000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `layout` is `""` for single-file books (the Batch A model — the whole
|
||||
file streams through the `/uploads` path), `"folder"` for a directory
|
||||
of track files and `"zip"` for a zip-of-audio book.
|
||||
- `tracks` is empty for single-file books and carries one entry per
|
||||
playable file otherwise. `start`/`end` are cumulative GLOBAL seconds —
|
||||
the same coordinate system chapters, bookmarks and
|
||||
`audio_offset_seconds` use.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------------- |
|
||||
| 400 | Invalid media item id |
|
||||
| 401 | Missing/invalid token |
|
||||
| 404 | Book not found |
|
||||
| 500 | audio_metadata failed to parse |
|
||||
@@ -0,0 +1,44 @@
|
||||
# Get Audio Track
|
||||
|
||||
Streams one playable track of a multi-file audiobook (folder-of-tracks
|
||||
or zip-of-audio book). Track indexes and bounds come from the track
|
||||
manifest on [Get Audio Chapters](get_audio_chapters.md).
|
||||
|
||||
**Endpoint**: `GET /api/media-items/{id}/audio/{trackIndex}`
|
||||
**Auth**: Required (JWT — Bearer header or session cookie; the web
|
||||
player's `<audio>` element authenticates by cookie)
|
||||
|
||||
## Serving
|
||||
|
||||
- **Folder books**: the track is served straight from its real file
|
||||
(`http.ServeContent`) — full `Range` support.
|
||||
- **Zip books**: the entry is extracted in place. Forward seeks discard
|
||||
bytes in the open stream; backward seeks re-open the entry and skip
|
||||
(deflate has no random access — cheap at audio bitrates). `Range`
|
||||
requests are honored.
|
||||
|
||||
Single-file books have no track manifest — they stream through the
|
||||
`/uploads` path exactly as before and this endpoint answers 404.
|
||||
|
||||
## Library visibility
|
||||
|
||||
The item's library must be visible to the requester; a hidden library
|
||||
answers **403** on every track (same gate as the download endpoint).
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | -------------------------------------------- |
|
||||
| 400 | Invalid media item id or track index |
|
||||
| 401 | Missing/invalid token |
|
||||
| 403 | Library not visible to the requester |
|
||||
| 404 | Book/track not found, or single-file book |
|
||||
| 500 | audio_metadata failed to parse |
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Range: bytes=0-1023" \
|
||||
"http://localhost:8765/api/media-items/550e8400-.../audio/0"
|
||||
```
|
||||
@@ -90,8 +90,11 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
### Absent data
|
||||
|
||||
`Audio Tracks` (Audiobookshelf's per-file count) is deliberately absent:
|
||||
Bookhoard audiobooks are one file per book until multi-file support
|
||||
lands, so a file count would just equal `items`. See HANDOFF item 9.
|
||||
multi-file audiobooks (folder-of-tracks / zip books) now ingest as ONE
|
||||
item whose track list lives in `audio_metadata.tracks`, so a file count
|
||||
would still not equal a per-book "tracks" metric without a new query.
|
||||
Add it by aggregating `jsonb_array_length(audio_metadata->'tracks')` if
|
||||
the panel is ever wanted.
|
||||
|
||||
## Error Responses
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Audiobook metadata: sources & precedence
|
||||
|
||||
Every audiobook ingestion path (single-file, folder-of-tracks, zip-of-
|
||||
audio) fills the same fields from the same ranked sources. Each source
|
||||
**only fills fields the earlier ones left empty** — curated data always
|
||||
outranks guesses — and every source is optional: a book still ingests
|
||||
(title from the filename) when all of them are absent. Admin edits made
|
||||
after ingest are protected by the metadata-override mechanism on
|
||||
rescans, regardless of source quality.
|
||||
|
||||
## Book-level metadata (title, author, narrators, series, …)
|
||||
|
||||
| Rank | Source | Feeds |
|
||||
|---|---|---|
|
||||
| 1 | **Sidecars** — `metadata.opf` (Calibre), `metadata.json` (Audiobookshelf/Libation, array-or-string tolerant) | title (+subtitle join), author, series + index, narrators, abridged, ASIN, tags/genres, dates, publisher, description |
|
||||
| 2 | **Folder structure** — `Author/Title`, `Author/Series, Book #N - Title`, `Author/Series/Title` | title, author, series + index |
|
||||
| 3 | **Embedded tags** (via ffprobe) | `album` → title; `album_artist` → author (`artist` fallback); `composer` → **narrators** (Audible's own convention); `grouping` → series; `date` → publish date; `genre` → genre; Audible freeform atoms `----:com.apple.iTunes:SERIES`/`:SERIES-PART`/`:SUBTITLE`/`:ASIN`/`:ABRIDGED` |
|
||||
| 4 | **Filename/folder name** | title last of all (created only — never lets a rescan blank a title) |
|
||||
|
||||
Abridged is the one guarded field: an explicit sidecar `false` wins even
|
||||
when a tag says `true` (the sidecar's presence is tracked, not just its
|
||||
value). Narrators from the composer tag stay ONE string — composer
|
||||
values have no reliable multi-narrator delimiter, and splitting
|
||||
"Vance, Simon" would manufacture fake people.
|
||||
|
||||
## Chapters (the `{index, title, start, end}` array)
|
||||
|
||||
| Rank | Source |
|
||||
|---|---|
|
||||
| 1 | Sidecar `metadata.json` `chapters[]` (curated, global seconds) |
|
||||
| 2 | Embedded m4b chapter atoms (`chpl` / QuickTime text track, read by ffprobe) |
|
||||
| 3 | **CUE sheet** — `.cue` next to the audio file (FILE-directive matched, `INDEX 01 MM:SS:FF` at 75 fps); the classic one-big-flac + cue rip shape |
|
||||
| 4 | Track boundaries — for folder/zip books, each file is a chapter |
|
||||
|
||||
## Track ordering (multi-file books)
|
||||
|
||||
| Rank | Source |
|
||||
|---|---|
|
||||
| 1 | **Playlist** — exactly one `.m3u`/`.m3u8` among the book root's direct children; its entry order wins and `#EXTINF` names the tracks |
|
||||
| 2 | **Tags** — `(disc, track)` sort when *every* track carries a track number (fixes flat `01.mp3` dumps from multi-disc rips) |
|
||||
| 3 | **Natural sort** of relative paths (`02` before `10`), recursive — the default |
|
||||
|
||||
Track display titles, highest first: `movement_name` → `title` tag →
|
||||
playlist `#EXTINF` → cleaned filename (leading ordinals/separators
|
||||
stripped).
|
||||
|
||||
## Book grouping (which files form one book)
|
||||
|
||||
- The book root is the nearest ancestor holding audio files DIRECTLY,
|
||||
merged upward through disc-part directories (`CD1`, `Disc 2`, `Part 3`,
|
||||
`05`) when the parent has no audio of its own.
|
||||
- Sibling directories are NEVER merged (two books under one author stay
|
||||
two books); a root with a single track falls back to the single-file
|
||||
model.
|
||||
- A lone `.zip` of audio files is its own book (layout "zip"); a folder
|
||||
of tracks is one book with `file_path` = the directory (layout
|
||||
"folder"); a single audio file is the Batch A model (no layout key).
|
||||
- Rescans take a fast path (track count + total size + known durations
|
||||
unchanged → no re-probe) and absorb pre-grouping one-track strays.
|
||||
|
||||
## Covers
|
||||
|
||||
| Rank | Source |
|
||||
|---|---|
|
||||
| 1 | Sidecar `cover.jpg` / `folder.jpg` / … next to the book |
|
||||
| 2 | **Embedded art** (`APIC` mp3, `covr` m4b, flac picture) — one optional ffmpeg call, written as a `<book>.cover.jpg` sibling following the EPUB convention |
|
||||
|
||||
## Accepted containers (audiobook libraries)
|
||||
|
||||
`.m4b` `.m4a` `.mp3` `.flac` `.zip` (book container) `.opus` `.ogg`
|
||||
`.oga` `.aac` `.wav` — all browser-playable; served with their real
|
||||
media types (`audio/ogg` per RFC 7845 for Opus-in-Ogg, etc.).
|
||||
|
||||
## Documented non-goals
|
||||
|
||||
- **ID3v2 CHAP/CTOC chapter frames** — the written MP3 standard for
|
||||
embedded chapters, near-zero audiobook adoption; would need a
|
||||
hand-rolled ID3v2.4 parser. Revisit only if real files show up.
|
||||
- **NFO sidecars** — no genuine audiobook NFO standard exists (Jellyfin's
|
||||
Bookshelf uses Calibre OPF, which we read).
|
||||
- **ReplayGain / EBU R128 loudness tags** — a playback feature, not
|
||||
ingestion; separate plan if ever wanted.
|
||||
- **`.wma`** — browsers cannot play it; excluded deliberately.
|
||||
+39
-4
@@ -2,7 +2,10 @@
|
||||
|
||||
## Color Themes
|
||||
|
||||
Bookhoard includes multiple color themes to suit your preferences:
|
||||
Bookhoard includes 37 color themes — 24 dark and 13 light — including the
|
||||
full set of [Omarchy](https://omarchy.org) defaults alongside the classics:
|
||||
|
||||
**Dark**
|
||||
|
||||
- **Tokyo Night** (default) - Dark blue/purple tones
|
||||
- **Dracula** - Dark purple accent colors
|
||||
@@ -14,13 +17,45 @@ Bookhoard includes multiple color themes to suit your preferences:
|
||||
- **Catppuccin Mocha** - Soothing pastel dark theme
|
||||
- **Catppuccin Macchiato** - Warm mid-tone theme
|
||||
- **Catppuccin Frappé** - Frothy cool tones
|
||||
- **Catppuccin Latte** - Warm light theme
|
||||
- **Vantablack** - Pure black
|
||||
- **Last Horizon** - Near-black with warm rose accents
|
||||
- **Ethereal** - Deep midnight blue with peach text
|
||||
- **Hackerman** - Terminal green on black
|
||||
- **Matte Black** - Flat black with amber accents
|
||||
- **Solitude** - Quiet blue-grey monochrome
|
||||
- **Retro 82** - Amber terminal on deep navy
|
||||
- **Osaka Jade** - Dark teal with jade accents
|
||||
- **Kanagawa** - Ink-dark waves and cream ink
|
||||
- **Miasma** - Swampy dark greens and ochre
|
||||
- **Lumon** - Cold teal, for the severed floor
|
||||
- **Ristretto** - Coffee-dark with warm peach
|
||||
- **Gruvbox** - Retro groove, warm dark
|
||||
- **Everforest** - Cozy green-toned dark
|
||||
|
||||
**Light**
|
||||
|
||||
- **Lupine** - Clean light with violet-blue accents (default light theme)
|
||||
- **Tokyo Night Day** - Daylight counterpart of the default
|
||||
- **Solarized Light** - Precision contrast on warm paper
|
||||
- **One Light** - Atom's light theme
|
||||
- **Ayu Light** - Bright and airy with orange accents
|
||||
- **GitHub Light** - Familiar, clean white
|
||||
- **Gruvbox Light** - Warm cream paper
|
||||
- **Everforest Light** - Soft green-tinted daylight
|
||||
- **Kanagawa Lotus** - Kanagawa's lotus-paper palette
|
||||
- **Catppuccin Latte** - Warm light pastels
|
||||
- **Rosé Pine** - Soft rose-tinted daylight
|
||||
- **Flexoki Light** - High-contrast warm paper
|
||||
- **White** - Pure monochrome white
|
||||
|
||||
### Changing Your Theme
|
||||
|
||||
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||
2. Pick a theme from the list — each option shows its color swatch, and your active theme is marked with a checkmark
|
||||
3. Your choice is saved automatically and synced across devices
|
||||
2. Use the **Dark / Light** switch at the top to show the half you want —
|
||||
it opens on the half your current theme lives in
|
||||
3. Pick a theme from the list — each option shows its color swatch, and your
|
||||
active theme is marked with a checkmark
|
||||
4. Your choice is saved automatically and synced across devices
|
||||
|
||||
On small screens, open the sidebar with the menu button in the top bar first.
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ type Querier interface {
|
||||
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
||||
// Count unlinked books for a device
|
||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||
// Banner count: unresolved queue rows across the caller's own devices.
|
||||
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)
|
||||
// COLLECTIONS QUERIES
|
||||
@@ -219,6 +221,11 @@ 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.
|
||||
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
|
||||
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
|
||||
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||
@@ -469,6 +476,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.
|
||||
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.
|
||||
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
||||
@@ -570,6 +579,11 @@ type Querier interface {
|
||||
UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error)
|
||||
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
|
||||
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
|
||||
// Focused refresh for multi-file audiobooks (folder-of-tracks / zip
|
||||
// books): rescans recompute the track manifest and the aggregate facts
|
||||
// that follow from it. Editorial fields stay untouched — admins may have
|
||||
// edited title/narrators after ingest.
|
||||
UpdateMediaItemAudioBookPayload(ctx context.Context, arg UpdateMediaItemAudioBookPayloadParams) (MediaItems, error)
|
||||
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
||||
// Update media item format
|
||||
UpdateMediaItemFormat(ctx context.Context, arg UpdateMediaItemFormatParams) (MediaItemFormats, error)
|
||||
@@ -607,6 +621,9 @@ type Querier interface {
|
||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||
// Ingest: insert or refresh the queue row for a device file that could not
|
||||
// be identified. One row per (device, content_id); re-syncs bump last_seen.
|
||||
UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
@@ -332,6 +332,21 @@ func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const CountUnresolvedUnlinkedBooksForUser = `-- 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
|
||||
`
|
||||
|
||||
// Banner count: unresolved queue rows across the caller's own devices.
|
||||
func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const CountUserDevices = `-- name: CountUserDevices :one
|
||||
SELECT COUNT(*) FROM devices WHERE user_id = $1
|
||||
`
|
||||
@@ -3825,7 +3840,7 @@ WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != ''
|
||||
GROUP BY library_id, series
|
||||
ORDER BY MAX(created_at) DESC
|
||||
ORDER BY MAX(created_at) DESC, library_id, series
|
||||
LIMIT $4 OFFSET $3
|
||||
`
|
||||
|
||||
@@ -3847,6 +3862,11 @@ type GetDistinctSeriesRow struct {
|
||||
// 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.
|
||||
func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetDistinctSeries,
|
||||
arg.LibraryID,
|
||||
@@ -11365,6 +11385,69 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
|
||||
return items, nil
|
||||
}
|
||||
|
||||
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
|
||||
FROM unlinked_books ub
|
||||
JOIN devices d ON ub.device_id = d.id
|
||||
WHERE d.user_id = $1 AND ub.resolved = false
|
||||
ORDER BY ub.last_seen_at DESC
|
||||
`
|
||||
|
||||
type ListUnresolvedUnlinkedBooksForUserRow 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"`
|
||||
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.
|
||||
func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListUnresolvedUnlinkedBooksForUserRow{}
|
||||
for rows.Next() {
|
||||
var i ListUnresolvedUnlinkedBooksForUserRow
|
||||
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.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 ListUsers = `-- name: ListUsers :many
|
||||
SELECT
|
||||
u.id,
|
||||
@@ -14219,6 +14302,111 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
|
||||
return i, err
|
||||
}
|
||||
|
||||
const UpdateMediaItemAudioBookPayload = `-- name: UpdateMediaItemAudioBookPayload :one
|
||||
UPDATE media_items
|
||||
SET file_size = $2,
|
||||
mime_type = $3,
|
||||
audio_metadata = $4,
|
||||
chapter_count = $5,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, abridged, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
||||
`
|
||||
|
||||
type UpdateMediaItemAudioBookPayloadParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
||||
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
||||
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
||||
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
|
||||
}
|
||||
|
||||
// Focused refresh for multi-file audiobooks (folder-of-tracks / zip
|
||||
// books): rescans recompute the track manifest and the aggregate facts
|
||||
// that follow from it. Editorial fields stay untouched — admins may have
|
||||
// edited title/narrators after ingest.
|
||||
func (q *Queries) UpdateMediaItemAudioBookPayload(ctx context.Context, arg UpdateMediaItemAudioBookPayloadParams) (MediaItems, error) {
|
||||
row := q.db.QueryRow(ctx, UpdateMediaItemAudioBookPayload,
|
||||
arg.ID,
|
||||
arg.FileSize,
|
||||
arg.MimeType,
|
||||
arg.AudioMetadata,
|
||||
arg.ChapterCount,
|
||||
)
|
||||
var i MediaItems
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.LibraryID,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.Isbn,
|
||||
&i.Description,
|
||||
&i.FilePath,
|
||||
&i.FileSize,
|
||||
&i.MimeType,
|
||||
&i.CoverImagePath,
|
||||
&i.Series,
|
||||
&i.SeriesNumber,
|
||||
&i.Tags,
|
||||
&i.Narrators,
|
||||
&i.Abridged,
|
||||
&i.Asin,
|
||||
&i.DatePublished,
|
||||
&i.Publisher,
|
||||
&i.Contributors,
|
||||
&i.Language,
|
||||
&i.Edition,
|
||||
&i.PageCount,
|
||||
&i.Genre,
|
||||
&i.CopyrightYear,
|
||||
&i.GoodreadsID,
|
||||
&i.OpenlibraryID,
|
||||
&i.GoogleBooksID,
|
||||
&i.AddedByAdminID,
|
||||
&i.CreatedAt,
|
||||
&i.ImportedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FormatGroup,
|
||||
&i.FormatMimetype,
|
||||
&i.IsReflowable,
|
||||
&i.HasFixedLayout,
|
||||
&i.TotalCharacters,
|
||||
&i.ChapterCount,
|
||||
&i.EntitlementID,
|
||||
&i.RevisionNumber,
|
||||
&i.KoboContentID,
|
||||
&i.KoboMetadata,
|
||||
&i.MangaType,
|
||||
&i.ReadingDirection,
|
||||
&i.SeriesCount,
|
||||
&i.Volume,
|
||||
&i.Imprint,
|
||||
&i.AgeRating,
|
||||
&i.WebUrl,
|
||||
&i.StoryArc,
|
||||
&i.IsBlackAndWhite,
|
||||
&i.MetadataNotes,
|
||||
&i.CommunityRating,
|
||||
&i.AlternateInfo,
|
||||
&i.ScanInformation,
|
||||
&i.Summary,
|
||||
&i.MetadataOverrides,
|
||||
&i.MissingScanCount,
|
||||
&i.ArchivedAt,
|
||||
&i.ChapterMetadata,
|
||||
&i.LibraryTypeName,
|
||||
&i.AudioMetadata,
|
||||
&i.TagsSearch,
|
||||
&i.ContributorsSearch,
|
||||
&i.NarratorsSearch,
|
||||
&i.FileSha256,
|
||||
&i.OpfIdentifier,
|
||||
&i.OpfUuid,
|
||||
&i.HashConfidence,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const UpdateMediaItemChapterMetadata = `-- name: UpdateMediaItemChapterMetadata :one
|
||||
UPDATE media_items
|
||||
SET chapter_metadata = $2, updated_at = NOW()
|
||||
@@ -15382,3 +15570,54 @@ func (q *Queries) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSetti
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const UpsertUnlinkedBook = `-- name: UpsertUnlinkedBook :one
|
||||
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (device_id, content_id) DO UPDATE
|
||||
SET file_path = EXCLUDED.file_path,
|
||||
title = EXCLUDED.title,
|
||||
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
|
||||
`
|
||||
|
||||
type UpsertUnlinkedBookParams struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
// Ingest: insert or refresh the queue row for a device file that could not
|
||||
// be identified. One row per (device, content_id); re-syncs bump last_seen.
|
||||
func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error) {
|
||||
row := q.db.QueryRow(ctx, UpsertUnlinkedBook,
|
||||
arg.DeviceID,
|
||||
arg.ContentID,
|
||||
arg.FilePath,
|
||||
arg.Title,
|
||||
arg.Author,
|
||||
arg.ConfidenceScore,
|
||||
)
|
||||
var i UnlinkedBooks
|
||||
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,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2619,6 +2619,34 @@ WHERE ub.resolved = false
|
||||
ORDER BY ub.last_seen_at DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- Ingest: insert or refresh the queue row for a device file that could not
|
||||
-- be identified. One row per (device, content_id); re-syncs bump last_seen.
|
||||
-- name: UpsertUnlinkedBook :one
|
||||
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (device_id, content_id) DO UPDATE
|
||||
SET file_path = EXCLUDED.file_path,
|
||||
title = EXCLUDED.title,
|
||||
author = EXCLUDED.author,
|
||||
confidence_score = EXCLUDED.confidence_score,
|
||||
last_seen_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- Banner count: unresolved queue rows across the caller's own devices.
|
||||
-- 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;
|
||||
|
||||
-- Page list: unresolved queue rows for the caller's own devices.
|
||||
-- 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
|
||||
ORDER BY ub.last_seen_at DESC;
|
||||
|
||||
-- ============================================
|
||||
-- CAROUSEL-STYLE DASHBOARD
|
||||
-- ============================================
|
||||
@@ -2852,7 +2880,12 @@ WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != ''
|
||||
GROUP BY library_id, series
|
||||
ORDER BY MAX(created_at) DESC
|
||||
-- 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
|
||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||
|
||||
-- name: GetDistinctSeriesCount :one
|
||||
@@ -3201,3 +3234,17 @@ WHERE mi.archived_at IS NULL
|
||||
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||
ORDER BY mi.file_size DESC, mi.title ASC
|
||||
LIMIT 5;
|
||||
|
||||
-- name: UpdateMediaItemAudioBookPayload :one
|
||||
-- Focused refresh for multi-file audiobooks (folder-of-tracks / zip
|
||||
-- books): rescans recompute the track manifest and the aggregate facts
|
||||
-- that follow from it. Editorial fields stay untouched — admins may have
|
||||
-- edited title/narrators after ingest.
|
||||
UPDATE media_items
|
||||
SET file_size = $2,
|
||||
mime_type = $3,
|
||||
audio_metadata = $4,
|
||||
chapter_count = $5,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, abridged, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence;
|
||||
@@ -25,6 +25,9 @@ func init() {
|
||||
"/app/database/schema/schema.sql",
|
||||
"../database/schema/schema.sql",
|
||||
"../../database/schema/schema.sql",
|
||||
// Integration tests run with CWD=cmd/server/tests, three levels
|
||||
// below the repo root — two up only reaches cmd/.
|
||||
"../../../database/schema/schema.sql",
|
||||
}
|
||||
|
||||
var content []byte
|
||||
|
||||
@@ -80,6 +80,48 @@ func (h *Handler) LinkBook(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Ownership: the device must belong to the caller.
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "user not authenticated",
|
||||
})
|
||||
}
|
||||
deviceOwned, err := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
UserID: user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to verify device",
|
||||
})
|
||||
}
|
||||
if !deviceOwned {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{
|
||||
"error": "Device not found",
|
||||
})
|
||||
}
|
||||
|
||||
// Family-lens: the link target must sit in a library the caller can
|
||||
// see — a hidden library's book can never be linked.
|
||||
visibleSet, visErr := h.visibleLibrarySetFor(c)
|
||||
if visErr != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to resolve visible libraries",
|
||||
})
|
||||
}
|
||||
item, itemErr := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: req.MediaItemID, Valid: true})
|
||||
if itemErr != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{
|
||||
"error": "Media item not found",
|
||||
})
|
||||
}
|
||||
if !item.LibraryID.Valid || !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{
|
||||
"error": "Book is not available to you",
|
||||
})
|
||||
}
|
||||
|
||||
alias, err := matchingService.LinkBook(c.Request().Context(), deviceID, &req)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
@@ -112,6 +154,27 @@ func (h *Handler) GetUnlinkedBooks(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "user not authenticated",
|
||||
})
|
||||
}
|
||||
deviceOwned, devErr := h.db.DeviceExistsForUser(c.Request().Context(), database.DeviceExistsForUserParams{
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
UserID: user.ID,
|
||||
})
|
||||
if devErr != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to verify device",
|
||||
})
|
||||
}
|
||||
if !deviceOwned {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{
|
||||
"error": "Device not found",
|
||||
})
|
||||
}
|
||||
|
||||
unlinked, err := matchingService.GetUnlinkedBooks(c.Request().Context(), deviceID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
@@ -509,10 +572,35 @@ func (h *Handler) DeleteDeviceFileAlias(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// visibleLibrarySetFor resolves the requesting user's visible-library set
|
||||
// (family-lens) from the request context identity.
|
||||
func (h *Handler) visibleLibrarySetFor(c *echo.Context) (map[string]bool, error) {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return nil, echo.NewHTTPError(http.StatusUnauthorized, "user not authenticated")
|
||||
}
|
||||
set, _, err := services.NewLibraryService(h.db).VisibleLibrarySet(
|
||||
c.Request().Context(), user.ID)
|
||||
return set, err
|
||||
}
|
||||
|
||||
// GetBookMatches handles GET /api/books/match
|
||||
func (h *Handler) GetBookMatches(c *echo.Context) error {
|
||||
matchingService := h.getMatchingService()
|
||||
|
||||
// Family-lens: restrict matches to libraries the requester can see
|
||||
// (absent-from-results — a hidden library's book simply never matches).
|
||||
visibleSet, visErr := h.visibleLibrarySetFor(c)
|
||||
if visErr != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to resolve visible libraries",
|
||||
})
|
||||
}
|
||||
visibleIDs := make([]string, 0, len(visibleSet))
|
||||
for id := range visibleSet {
|
||||
visibleIDs = append(visibleIDs, id)
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
identifiers := c.QueryParams()["identifier"]
|
||||
sha256 := c.QueryParam("sha256")
|
||||
@@ -532,11 +620,12 @@ func (h *Handler) GetBookMatches(c *echo.Context) error {
|
||||
}
|
||||
|
||||
req := &services.BookQueryRequest{
|
||||
Identifiers: identifiers,
|
||||
SHA256: sha256,
|
||||
Title: title,
|
||||
Author: author,
|
||||
FileSize: fileSize,
|
||||
Identifiers: identifiers,
|
||||
SHA256: sha256,
|
||||
Title: title,
|
||||
Author: author,
|
||||
FileSize: fileSize,
|
||||
VisibleLibraryIDs: visibleIDs,
|
||||
}
|
||||
|
||||
response, err := matchingService.QueryBooks(c.Request().Context(), req)
|
||||
|
||||
@@ -451,6 +451,10 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
||||
for _, book := range req.Books {
|
||||
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
|
||||
if !mediaItemID.Valid {
|
||||
// Unidentified file: queue it for manual identification
|
||||
// (unlinked-books page) instead of silently dropping the
|
||||
// file's progress. One row per (device, content id).
|
||||
h.queueUnlinkedBook(c, device.ID, book)
|
||||
bookResults = append(bookResults, KOReaderBookSyncResult{
|
||||
SHA256: book.SHA256,
|
||||
Synced: false,
|
||||
@@ -498,9 +502,47 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) {
|
||||
// queueUnlinkedBook records a device file that could not be identified, so
|
||||
// the user can resolve it on the unlinked-books page. One row per
|
||||
// (device, content id); re-syncs refresh the row instead of duplicating it.
|
||||
// Best-effort: queueing failures never fail the sync response.
|
||||
func (h *KOReaderHandler) queueUnlinkedBook(c *echo.Context, deviceID pgtype.UUID, book KOReaderBookProgress) {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
contentID := book.UUID
|
||||
if contentID == "" {
|
||||
contentID = book.SHA256
|
||||
}
|
||||
if contentID == "" {
|
||||
// Nothing stable to identify the file by — it could never resolve.
|
||||
return
|
||||
}
|
||||
|
||||
var title pgtype.Text
|
||||
if book.Title != "" {
|
||||
title = pgtype.Text{String: book.Title, Valid: true}
|
||||
}
|
||||
var author pgtype.Text
|
||||
if len(book.Authors) > 0 && book.Authors[0] != "" {
|
||||
author = pgtype.Text{String: book.Authors[0], Valid: true}
|
||||
}
|
||||
var filePath pgtype.Text
|
||||
if book.FilePath != "" {
|
||||
filePath = pgtype.Text{String: book.FilePath, Valid: true}
|
||||
}
|
||||
|
||||
_, _ = h.db.UpsertUnlinkedBook(ctx, database.UpsertUnlinkedBookParams{
|
||||
DeviceID: deviceID,
|
||||
ContentID: contentID,
|
||||
FilePath: filePath,
|
||||
Title: title,
|
||||
Author: author,
|
||||
ConfidenceScore: pgtype.Float8{Float64: 0, Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) { ctx := c.Request().Context()
|
||||
|
||||
// Priority 1: UUID provided (highest confidence - 1.0)
|
||||
if book.UUID != "" {
|
||||
mediaUUID, err := uuid.Parse(book.UUID)
|
||||
|
||||
+231
-4
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
wsync "bookhoard/internal/sync"
|
||||
@@ -2550,9 +2551,16 @@ func (mh *MediaHandler) ServeFile(c *echo.Context) error {
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
fullInfo, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
||||
}
|
||||
// Folder-of-tracks audiobooks store their book root DIRECTORY in
|
||||
// file_path; there is no single file to serve and ServeFile must not
|
||||
// fall through to directory listing behavior.
|
||||
if fullInfo.IsDir() {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "multi-file books have no single download"})
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(fullPath))
|
||||
contentType := services.MimeTypes[ext]
|
||||
@@ -2648,9 +2656,12 @@ func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file
|
||||
|
||||
// GetAudioChapters handles GET /api/media-items/:id/chapters — the
|
||||
// format-abstraction chapter list for audiobooks (media_items
|
||||
// .audio_metadata). Embedded m4b chapters and folder-as-book files write
|
||||
// the same shape. Empty list when the item isn't audio or hasn't been
|
||||
// probed.
|
||||
// .audio_metadata). Embedded m4b chapters, folder-of-tracks books and zip
|
||||
// books write the same shape. Multi-file books additionally carry
|
||||
// `layout` and a `tracks` manifest whose URLs point at the per-track
|
||||
// streaming endpoint; single-file books keep the empty layout their
|
||||
// existing consumers expect. Empty list when the item isn't audio or
|
||||
// hasn't been probed.
|
||||
func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
|
||||
mediaID := c.Param("id")
|
||||
mediaUUID, err := uuid.Parse(mediaID)
|
||||
@@ -2662,6 +2673,20 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
||||
}
|
||||
if !item.LibraryID.Valid {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book has no library"})
|
||||
}
|
||||
|
||||
// Library visibility gate (family-lens rule): chapter lists are media
|
||||
// metadata — a hidden library must not leak them any more than its
|
||||
// file bytes.
|
||||
visibleSet, _, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
|
||||
}
|
||||
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
var payload services.AudioMetadataPayload
|
||||
if len(item.AudioMetadata) > 0 {
|
||||
@@ -2670,10 +2695,212 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
tracks := make([]map[string]interface{}, 0, len(payload.Tracks))
|
||||
for _, tr := range payload.Tracks {
|
||||
tracks = append(tracks, map[string]interface{}{
|
||||
"index": tr.Index,
|
||||
"url": fmt.Sprintf("/api/media-items/%s/audio/%d", mediaID, tr.Index),
|
||||
"title": tr.Title,
|
||||
"path": tr.Path,
|
||||
"start": tr.Start,
|
||||
"end": tr.End,
|
||||
"duration_seconds": tr.DurationSeconds,
|
||||
"size_bytes": tr.SizeBytes,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"media_item_id": item.ID,
|
||||
"chapters": payload.Chapters,
|
||||
"duration_seconds": payload.DurationSeconds,
|
||||
"codec_profile": payload.CodecProfile,
|
||||
"layout": payload.Layout,
|
||||
"tracks": tracks,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the
|
||||
// per-track byte stream for multi-file audiobooks (folder books serve the
|
||||
// real file; zip books extract the entry in place). Library visibility is
|
||||
// the permission gate, same as ServeFile.
|
||||
func (mh *MediaHandler) GetAudioTrack(c *echo.Context) error {
|
||||
mediaUUID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
trackIndex, err := strconv.Atoi(c.Param("trackIndex"))
|
||||
if err != nil || trackIndex < 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid track index"})
|
||||
}
|
||||
|
||||
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
||||
}
|
||||
if !item.LibraryID.Valid {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book has no library"})
|
||||
}
|
||||
|
||||
// Library visibility gate (family-lens rule): a hidden library means
|
||||
// its audiobooks' bytes are not reachable either.
|
||||
visibleSet, _, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
|
||||
}
|
||||
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
var payload services.AudioMetadataPayload
|
||||
if len(item.AudioMetadata) > 0 {
|
||||
if err := json.Unmarshal(item.AudioMetadata, &payload); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to parse audio metadata"})
|
||||
}
|
||||
}
|
||||
if trackIndex >= len(payload.Tracks) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "track not found"})
|
||||
}
|
||||
track := payload.Tracks[trackIndex]
|
||||
|
||||
switch payload.Layout {
|
||||
case "folder":
|
||||
fullPath, err := mh.getFullFilePath(c.Request().Context(), item.LibraryID, track.Path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "track file not found"})
|
||||
}
|
||||
f, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "track file not found"})
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
info, err := f.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "track file not found"})
|
||||
}
|
||||
c.Response().Header().Set("Content-Type", audioTrackContentType(track.Path))
|
||||
c.Response().Header().Set("Cache-Control", "private, max-age=86400")
|
||||
// http.ServeContent wants a ReadSeeker for Range support; the os
|
||||
// file provides both.
|
||||
http.ServeContent(c.Response(), c.Request(), filepath.Base(fullPath), info.ModTime(), f)
|
||||
return nil
|
||||
|
||||
case "zip":
|
||||
zipPath, err := mh.getFullFilePath(c.Request().Context(), item.LibraryID, item.FilePath)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "archive not found"})
|
||||
}
|
||||
seeker, err := newZipEntrySeeker(zipPath, track.Path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "track not found in archive"})
|
||||
}
|
||||
defer func() { _ = seeker.Close() }()
|
||||
c.Response().Header().Set("Content-Type", audioTrackContentType(track.Path))
|
||||
c.Response().Header().Set("Cache-Control", "private, max-age=86400")
|
||||
// Zip entries re-open+skip for backward seeks (deflate has no
|
||||
// random access), which audio bitrates make cheap enough.
|
||||
http.ServeContent(c.Response(), c.Request(), filepath.Base(track.Path), time.Time{}, seeker)
|
||||
return nil
|
||||
|
||||
default:
|
||||
// Single-file books stream through the /uploads path; the track
|
||||
// endpoint has nothing to slice.
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book has no track manifest"})
|
||||
}
|
||||
}
|
||||
|
||||
// audioTrackContentType maps a track path/entry name to its mime type.
|
||||
func audioTrackContentType(name string) string {
|
||||
if m := services.MimeTypes[strings.ToLower(filepath.Ext(name))]; m != "" {
|
||||
return m
|
||||
}
|
||||
return "audio/mpeg"
|
||||
}
|
||||
|
||||
// zipEntrySeeker is an io.ReadSeeker over one zip entry. Forward seeks
|
||||
// discard bytes in the open stream; backward seeks re-open the entry and
|
||||
// skip — the only option over a deflate stream, and cheap at audio
|
||||
// bitrates. Owns the archive reader for the life of the request.
|
||||
type zipEntrySeeker struct {
|
||||
zrc *zip.ReadCloser
|
||||
rc io.ReadCloser
|
||||
pos int64
|
||||
size int64
|
||||
entryFn func() (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
func newZipEntrySeeker(zipPath, entryName string) (*zipEntrySeeker, error) {
|
||||
zrc, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entry *zip.File
|
||||
for _, f := range zrc.File {
|
||||
if filepath.ToSlash(f.Name) == filepath.ToSlash(entryName) {
|
||||
entry = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if entry == nil {
|
||||
_ = zrc.Close()
|
||||
return nil, fmt.Errorf("entry %q not found", entryName)
|
||||
}
|
||||
open := func() (io.ReadCloser, error) { return entry.Open() }
|
||||
rc, err := open()
|
||||
if err != nil {
|
||||
_ = zrc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &zipEntrySeeker{
|
||||
zrc: zrc,
|
||||
rc: rc,
|
||||
size: int64(entry.UncompressedSize64),
|
||||
entryFn: open,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (z *zipEntrySeeker) Read(p []byte) (int, error) {
|
||||
n, err := z.rc.Read(p)
|
||||
z.pos += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (z *zipEntrySeeker) Seek(offset int64, whence int) (int64, error) {
|
||||
var target int64
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
target = offset
|
||||
case io.SeekCurrent:
|
||||
target = z.pos + offset
|
||||
case io.SeekEnd:
|
||||
target = z.size + offset
|
||||
default:
|
||||
return z.pos, fmt.Errorf("invalid whence %d", whence)
|
||||
}
|
||||
if target < 0 {
|
||||
return z.pos, fmt.Errorf("negative seek target %d", target)
|
||||
}
|
||||
if target < z.pos {
|
||||
// Deflate streams can't rewind: restart the entry and skip.
|
||||
if err := z.rc.Close(); err != nil {
|
||||
return z.pos, err
|
||||
}
|
||||
rc, err := z.entryFn()
|
||||
if err != nil {
|
||||
return z.pos, err
|
||||
}
|
||||
z.rc = rc
|
||||
z.pos = 0
|
||||
}
|
||||
if remaining := target - z.pos; remaining > 0 {
|
||||
if _, err := io.CopyN(io.Discard, z.rc, remaining); err != nil {
|
||||
return z.pos, err
|
||||
}
|
||||
z.pos = target
|
||||
}
|
||||
return z.pos, nil
|
||||
}
|
||||
|
||||
func (z *zipEntrySeeker) Close() error {
|
||||
_ = z.rc.Close()
|
||||
return z.zrc.Close()
|
||||
}
|
||||
@@ -916,9 +916,16 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
||||
}
|
||||
// Folder-of-tracks audiobooks point at a book root DIRECTORY: there
|
||||
// is no single file to stream for OPDS clients (a directory fd reads
|
||||
// EISDIR). Zip books are real files and keep working.
|
||||
if fileInfo.IsDir() {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "multi-file books have no single download"})
|
||||
}
|
||||
|
||||
// Open file
|
||||
file, err := os.Open(filePath)
|
||||
@@ -927,11 +934,7 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Get file info
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get file info"})
|
||||
}
|
||||
// Get file info (fileInfo already statted above)
|
||||
|
||||
// Set headers
|
||||
filename := filepath.Base(filePath)
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/services"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
@@ -209,6 +210,28 @@ func (h *Handler) GetScanSummary(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// CancelScan handles POST /api/scanner/cancel/:jobId (admin): requests
|
||||
// cancellation of a pending or running scan job. Cancellation is
|
||||
// cooperative — the file currently being processed finishes, the walk
|
||||
// stops at its next context check, and the archive pass is skipped so
|
||||
// unvisited files are never treated as missing.
|
||||
func (h *Handler) CancelScan(c *echo.Context) error {
|
||||
jobID := c.Param("jobId")
|
||||
|
||||
if err := h.worker.CancelJob(jobID); err != nil {
|
||||
if errors.Is(err, services.ErrJobNotFound) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "job not found"})
|
||||
}
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "scan cancellation requested",
|
||||
"job_id": jobID,
|
||||
"status": "cancelled",
|
||||
})
|
||||
}
|
||||
|
||||
// StartWatchModeForLibrary starts watching a specific library's folders
|
||||
func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype.UUID, adminID pgtype.UUID) error {
|
||||
h.mu.Lock()
|
||||
|
||||
+166
-91
@@ -475,8 +475,18 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
}
|
||||
|
||||
userUUID, userUUIDErr := uuid.Parse(user.ID)
|
||||
if userUUIDErr != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
unlinkedCount, countErr := cfg.Queries.CountUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if countErr != nil {
|
||||
log.Printf("CountUnresolvedUnlinkedBooksForUser failed: %v", countErr)
|
||||
unlinkedCount = 0
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg).Render(c.Request().Context(), &buf)
|
||||
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg, int(unlinkedCount)).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -660,18 +670,19 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
})
|
||||
|
||||
// Collection detail page (works for both system and user collections)
|
||||
// Collection detail page. Book loading lives in loadCollectionBooks —
|
||||
// shared verbatim with the books-grid partial below so the SSR page and
|
||||
// the partial can never drift (rendering-ownership rule).
|
||||
frontendProtected.GET("/collections/:id", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
// Parse collection ID from URL
|
||||
collectionID := c.Param("id")
|
||||
collUUID, err := uuid.Parse(collectionID)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Invalid collection ID", "invalid_id")
|
||||
}
|
||||
// Fetch collection details
|
||||
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
@@ -679,106 +690,146 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
return renderErrorPage(c, "Error loading collection", "collection_load_error")
|
||||
}
|
||||
// Fetch books in collection
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
var books []handlers.BookInfo
|
||||
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
libraryID := libRes.LibraryID
|
||||
|
||||
var libScope pgtype.UUID
|
||||
if !libRes.IsAll {
|
||||
if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil {
|
||||
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
}
|
||||
colService := cfg.CollectionHandler.CollectionService()
|
||||
isSmart := colService.IsSmartCollection(collection.AutoAssignRules)
|
||||
|
||||
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
|
||||
if visErr != nil {
|
||||
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
|
||||
visibleIDs = nil
|
||||
}
|
||||
|
||||
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
|
||||
|
||||
if isSmart {
|
||||
// Smart (dynamic) collection: membership is evaluated live —
|
||||
// visible libraries, live items only, any enabled rule matches.
|
||||
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
||||
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
|
||||
LibraryID: libScope,
|
||||
VisibleIDs: visibleIDs,
|
||||
})
|
||||
if evalErr != nil {
|
||||
return renderErrorPage(c, "Error loading books", "books_load_error")
|
||||
}
|
||||
books = handlers.BookCardsFromMediaItems(matches, libTypes)
|
||||
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
||||
dashboardSvc := services.NewDashboardService(cfg.Queries)
|
||||
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
|
||||
if secErr != nil {
|
||||
return renderErrorPage(c, "Error loading books", "books_load_error")
|
||||
}
|
||||
|
||||
for _, section := range sections {
|
||||
if section.CollectionID.String() == collectionID {
|
||||
books = handlers.BookCardsFromMediaItems(section.Items, libTypes)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if libraryID != "" && !libRes.IsAll {
|
||||
libUUID, parseErr := uuid.Parse(libraryID)
|
||||
if parseErr != nil {
|
||||
return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
|
||||
}
|
||||
|
||||
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
|
||||
database.GetCollectionItemsForDashboardParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Limit: pgtype.Int4{Int32: 1000, Valid: true},
|
||||
})
|
||||
if collErr != nil {
|
||||
books = handlers.BookCardsFromCollectionDashRows(nil, libTypes)
|
||||
} else {
|
||||
// The query filters excluded rows in SQL.
|
||||
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
|
||||
}
|
||||
} else {
|
||||
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
||||
if collErr != nil {
|
||||
collItems = nil
|
||||
}
|
||||
books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes)
|
||||
}
|
||||
}
|
||||
// Build collection data
|
||||
colData := templates.CollectionData{
|
||||
ID: collectionID,
|
||||
Name: collection.Name,
|
||||
Description: collection.Description.String,
|
||||
Color: collection.Color.String,
|
||||
Icon: collection.Icon.String,
|
||||
IsSystem: collection.IsSystemCollection.Bool,
|
||||
IsSmart: isSmart,
|
||||
BookCount: len(books),
|
||||
books, libRes, isSmart, errCode := cfg.loadCollectionBooks(c, user, collection, collectionID)
|
||||
if errCode != "" {
|
||||
msg := map[string]string{
|
||||
"books_load_error": "Error loading books",
|
||||
"invalid_library_id": "Invalid library ID",
|
||||
"invalid_id": "Invalid collection ID",
|
||||
}[errCode]
|
||||
return renderErrorPage(c, msg, errCode)
|
||||
}
|
||||
colData := collectionDataFromRow(collection, collectionID, isSmart, len(books))
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.CollectionDetail(user, colData, books, libraryID, libRes.Libraries).Render(c.Request().Context(), &buf)
|
||||
err = templates.CollectionDetail(user, colData, books, libRes.LibraryID, libRes.Libraries).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Books-grid partial: the collection detail page's #books-container
|
||||
// content. The library switcher swaps this in (htmx.ajax) instead of
|
||||
// re-rendering the grid client-side from JSON. Family-lens scoped via
|
||||
// loadCollectionBooksLite — the requested library must be in the
|
||||
// caller's visible set, without the page-only library resolution.
|
||||
frontendProtected.GET("/collections/:id/books-grid", func(c *echo.Context) error {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
|
||||
}
|
||||
userUUID, uuidErr := uuid.Parse(user.ID.String())
|
||||
if uuidErr != nil {
|
||||
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
|
||||
}
|
||||
collectionID := c.Param("id")
|
||||
collUUID, parseErr := uuid.Parse(collectionID)
|
||||
if parseErr != nil {
|
||||
return c.HTML(http.StatusBadRequest, "<div>Invalid collection ID</div>")
|
||||
}
|
||||
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusNotFound, "<div>Collection not found</div>")
|
||||
}
|
||||
books, isSmart, errCode := cfg.loadCollectionBooksLite(c, userUUID, collection, collectionID)
|
||||
if errCode == "library_forbidden" {
|
||||
return c.HTML(http.StatusForbidden, "<div>Library not available</div>")
|
||||
}
|
||||
if errCode != "" {
|
||||
return c.HTML(http.StatusBadRequest, "<div>Error loading books</div>")
|
||||
}
|
||||
colData := collectionDataFromRow(collection, collectionID, isSmart, len(books))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if renderErr := templates.CollectionBooksGrid(colData, books).Render(c.Request().Context(), &buf); renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Week-widget fragment: the collapsible "This Week" strip re-fetches
|
||||
// itself here (htmx) whenever the library switcher dispatches
|
||||
// week-refresh, carrying the current #library-select value. Same
|
||||
// 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).
|
||||
frontendProtected.GET("/unlinked-books", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
userUUID, userUUIDErr := uuid.Parse(user.ID)
|
||||
if userUUIDErr != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
rows, listErr := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if listErr != nil {
|
||||
log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr)
|
||||
rows = []database.ListUnresolvedUnlinkedBooksForUserRow{}
|
||||
}
|
||||
|
||||
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, user.Timezone),
|
||||
ConfidenceScore: r.ConfidenceScore.Float64,
|
||||
})
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if renderErr := templates.UnlinkedBooks(user, books).Render(c.Request().Context(), &buf); renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// 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,
|
||||
// JS only swaps. NOTE: nothing routes to the unlinked-books page yet;
|
||||
// this endpoint is ready for whichever UI wires it up.
|
||||
frontendProtected.GET("/unlinked-books/matches", func(c *echo.Context) error {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
|
||||
}
|
||||
userUUID, uuidErr := uuid.Parse(user.ID.String())
|
||||
if uuidErr != nil {
|
||||
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
|
||||
}
|
||||
set, _, setErr := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if setErr != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "<div>Failed to resolve visible libraries</div>")
|
||||
}
|
||||
visibleIDs := make([]string, 0, len(set))
|
||||
for id := range set {
|
||||
visibleIDs = append(visibleIDs, id)
|
||||
}
|
||||
req := &services.BookQueryRequest{
|
||||
SHA256: c.QueryParam("sha256"),
|
||||
Title: c.QueryParam("title"),
|
||||
VisibleLibraryIDs: visibleIDs,
|
||||
}
|
||||
resp, matchErr := services.NewBookMatchingService(cfg.Queries).QueryBooks(c.Request().Context(), req)
|
||||
if matchErr != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "<div>Failed to search</div>")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if renderErr := templates.UnlinkedMatchCards(c.QueryParam("progress_id"), resp.Matches).Render(c.Request().Context(), &buf); renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
frontendProtected.GET("/dashboard/week-widget", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
@@ -945,6 +996,29 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
// Conflicts list partial: fresh list plus the stats bar as an
|
||||
// out-of-band swap. The client refreshes through this instead of
|
||||
// re-rendering conflict cards from JSON (rendering-ownership rule:
|
||||
// templ renders, JS only swaps).
|
||||
frontendProtected.GET("/conflicts/list", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
|
||||
}
|
||||
conflictsData, total, unresolved, dataErr := cfg.ConflictHandler.GetConflictsData(c)
|
||||
if dataErr != nil {
|
||||
log.Printf("GetConflictsData failed: %v", dataErr)
|
||||
conflictsData = []handlers.ConflictDetailResponse{}
|
||||
total = 0
|
||||
unresolved = 0
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if renderErr := templates.ConflictsRefreshFragment(user, conflictsData, total, unresolved).Render(c.Request().Context(), &buf); renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Analytics page → /stats (the rebuilt reading-statistics page;
|
||||
// nobody bookmarks orphan pages twice).
|
||||
@@ -1118,7 +1192,8 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
|
||||
var buf bytes.Buffer
|
||||
archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context())
|
||||
err = templates.AdminLibrary(user, libData, userData, archivedCount).Render(c.Request().Context(), &buf)
|
||||
libraryTypes := libraryTypeOptionsFor(c.Request().Context(), cfg)
|
||||
err = templates.AdminLibrary(user, libData, userData, archivedCount, libraryTypes).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
"bookhoard/templates"
|
||||
|
||||
@@ -257,3 +259,170 @@ func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgty
|
||||
}
|
||||
return cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), uuidToPGType(userU))
|
||||
}
|
||||
|
||||
// collectionDataFromRow builds the template collection payload shared by
|
||||
// the collection detail page and its books-grid partial.
|
||||
func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData {
|
||||
return templates.CollectionData{
|
||||
ID: collectionID,
|
||||
Name: collection.Name,
|
||||
Description: collection.Description.String,
|
||||
Color: collection.Color.String,
|
||||
Icon: collection.Icon.String,
|
||||
IsSystem: collection.IsSystemCollection.Bool,
|
||||
IsSmart: isSmart,
|
||||
BookCount: bookCount,
|
||||
}
|
||||
}
|
||||
|
||||
// collectionBooksForScope runs the per-scope book loading shared by the
|
||||
// detail page and the books-grid partial: smart evaluation, a dashboard
|
||||
// query section, or manual membership. Callers resolve the scope:
|
||||
// libUUID/libScope/isAll/scoped per their own resolution rules.
|
||||
func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) {
|
||||
colService := cfg.CollectionHandler.CollectionService()
|
||||
isSmart = colService.IsSmartCollection(collection.AutoAssignRules)
|
||||
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
|
||||
|
||||
if isSmart {
|
||||
// Smart (dynamic) collection: membership is evaluated live —
|
||||
// visible libraries, live items only, any enabled rule matches.
|
||||
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
||||
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
|
||||
LibraryID: libScope,
|
||||
VisibleIDs: visibleIDs,
|
||||
})
|
||||
if evalErr != nil {
|
||||
return nil, isSmart, "books_load_error"
|
||||
}
|
||||
books = handlers.BookCardsFromMediaItems(matches, libTypes)
|
||||
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
||||
dashboardSvc := services.NewDashboardService(cfg.Queries)
|
||||
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
||||
if secErr != nil {
|
||||
return nil, isSmart, "books_load_error"
|
||||
}
|
||||
|
||||
for _, section := range sections {
|
||||
if section.CollectionID.String() == collectionID {
|
||||
books = handlers.BookCardsFromMediaItems(section.Items, libTypes)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if scoped && libUUID.Valid {
|
||||
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
|
||||
database.GetCollectionItemsForDashboardParams{
|
||||
CollectionID: collUUID,
|
||||
LibraryID: libUUID,
|
||||
Limit: pgtype.Int4{Int32: 1000, Valid: true},
|
||||
})
|
||||
if collErr != nil {
|
||||
books = handlers.BookCardsFromCollectionDashRows(nil, libTypes)
|
||||
} else {
|
||||
// The query filters excluded rows in SQL.
|
||||
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
|
||||
}
|
||||
} else {
|
||||
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), collUUID)
|
||||
if collErr != nil {
|
||||
collItems = nil
|
||||
}
|
||||
books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes)
|
||||
}
|
||||
}
|
||||
return books, isSmart, ""
|
||||
}
|
||||
|
||||
// loadCollectionBooks resolves the books shown for a collection detail
|
||||
// page: full library resolution (visible-library payload, folder
|
||||
// reachability, counts) because the page render needs it. The books-grid
|
||||
// partial uses the lighter loadCollectionBooksLite instead.
|
||||
// errCode is "" on success, otherwise a renderErrorPage code.
|
||||
func (cfg *Config) loadCollectionBooks(c *echo.Context, user templates.User, collection database.Collections, collectionID string) (books []handlers.BookInfo, libRes LibraryResolution, isSmart bool, errCode string) {
|
||||
collUUID, parseErr := uuid.Parse(collectionID)
|
||||
if parseErr != nil {
|
||||
return nil, libRes, isSmart, "invalid_id"
|
||||
}
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
|
||||
libRes = resolveLibrary(c, cfg, user.ID)
|
||||
libraryID := libRes.LibraryID
|
||||
|
||||
var libScope pgtype.UUID
|
||||
if !libRes.IsAll {
|
||||
if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil {
|
||||
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
|
||||
if visErr != nil {
|
||||
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
|
||||
visibleIDs = nil
|
||||
}
|
||||
|
||||
scoped := libraryID != "" && !libRes.IsAll
|
||||
books, isSmart, errCode = cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID, libRes.LibUUID, libScope, scoped, visibleIDs)
|
||||
return books, libRes, isSmart, errCode
|
||||
}
|
||||
|
||||
// loadCollectionBooksLite is the books-grid partial's loader. The switcher
|
||||
// refresh only needs "is the requested library visible, and its books" —
|
||||
// so it validates the requested library against the caller's visible set
|
||||
// directly (family-lens: out-of-set is rejected) and skips the page-only
|
||||
// library resolution (folder reachability, counts, dropdown payload).
|
||||
func (cfg *Config) loadCollectionBooksLite(c *echo.Context, userUUID uuid.UUID, collection database.Collections, collectionID string) (books []handlers.BookInfo, isSmart bool, errCode string) {
|
||||
collUUID, parseErr := uuid.Parse(collectionID)
|
||||
if parseErr != nil {
|
||||
return nil, isSmart, "invalid_id"
|
||||
}
|
||||
libraryID := c.QueryParam("library_id")
|
||||
|
||||
var libUUID pgtype.UUID
|
||||
if libraryID != "" {
|
||||
parsed, parseErr := uuid.Parse(libraryID)
|
||||
if parseErr != nil {
|
||||
return nil, isSmart, "invalid_library_id"
|
||||
}
|
||||
set, _, setErr := cfg.LibraryService.VisibleLibrarySet(
|
||||
c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if setErr != nil {
|
||||
return nil, isSmart, "books_load_error"
|
||||
}
|
||||
if !set[libraryID] {
|
||||
return nil, isSmart, "library_forbidden"
|
||||
}
|
||||
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
|
||||
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
|
||||
if visErr != nil {
|
||||
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
|
||||
visibleIDs = nil
|
||||
}
|
||||
|
||||
return cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID,
|
||||
libUUID, libUUID, libraryID != "", visibleIDs)
|
||||
}
|
||||
|
||||
// libraryTypeOptionsFor loads the library_types rows for the create-library
|
||||
// dropdowns (admin page + setup wizard), ordered for display. On DB failure
|
||||
// it falls back to the built-in type names so the form stays usable —
|
||||
// CreateLibrary resolves the name against the DB, so this is never
|
||||
// authoritative.
|
||||
func libraryTypeOptionsFor(ctx context.Context, cfg *Config) []database.LibraryTypes {
|
||||
types, err := cfg.LibraryService.GetLibraryTypes(ctx)
|
||||
if err != nil {
|
||||
log.Printf("libraryTypeOptionsFor: GetLibraryTypes failed, using built-in type names: %v", err)
|
||||
for _, name := range []string{
|
||||
services.LibraryTypeEbooks,
|
||||
services.LibraryTypeComics,
|
||||
services.LibraryTypeManga,
|
||||
services.LibraryTypeAudiobooks,
|
||||
} {
|
||||
types = append(types, database.LibraryTypes{Name: name})
|
||||
}
|
||||
}
|
||||
return templates.OrderedLibraryTypes(types)
|
||||
}
|
||||
@@ -18,10 +18,11 @@ func registerMediaRoutes(cfg *Config) {
|
||||
// Book download endpoint - same ServeFile flow as /uploads/library-:id/*
|
||||
// (JWT + library-visibility gated), addressed by media item ID.
|
||||
protected.GET("/media-items/:id/download", cfg.MediaHandler.ServeFile)
|
||||
// The format-abstraction chapter list (audio_metadata). Streaming
|
||||
// itself rides the existing /uploads Range-serving path with cookie
|
||||
// auth — no separate endpoint.
|
||||
// The format-abstraction chapter list (audio_metadata). Single-file
|
||||
// books stream through the existing /uploads Range-serving path;
|
||||
// multi-file books additionally expose per-track streaming.
|
||||
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
|
||||
protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack)
|
||||
|
||||
// Media rating routes (all authenticated users)
|
||||
protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating)
|
||||
|
||||
@@ -217,6 +217,11 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
mime.AddExtensionType(".m4a", "audio/mp4")
|
||||
mime.AddExtensionType(".mp3", "audio/mpeg")
|
||||
mime.AddExtensionType(".flac", "audio/flac")
|
||||
mime.AddExtensionType(".opus", "audio/ogg")
|
||||
mime.AddExtensionType(".ogg", "audio/ogg")
|
||||
mime.AddExtensionType(".oga", "audio/ogg")
|
||||
mime.AddExtensionType(".aac", "audio/aac")
|
||||
mime.AddExtensionType(".wav", "audio/wav")
|
||||
e := cfg.Echo
|
||||
|
||||
// Set up validator
|
||||
|
||||
@@ -17,6 +17,7 @@ func registerScannerRoutes(protected, admin *echo.Group, h *handlers.Handler) {
|
||||
admin.POST("/scanner/scan", h.ScanLibrary)
|
||||
admin.POST("/scanner/start", h.StartScanner)
|
||||
admin.POST("/scanner/stop", h.StopScanner)
|
||||
admin.POST("/scanner/cancel/:jobId", h.CancelScan)
|
||||
admin.GET("/scanner/status/:jobId", h.GetScanStatus)
|
||||
admin.GET("/scanner/active", h.GetActiveScans)
|
||||
|
||||
|
||||
@@ -81,7 +81,8 @@ func registerSetupRoutes(cfg *Config) {
|
||||
return c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := templates.Setup().Render(c.Request().Context(), &buf); err != nil {
|
||||
libraryTypes := libraryTypeOptionsFor(c.Request().Context(), cfg)
|
||||
if err := templates.Setup(libraryTypes).Render(c.Request().Context(), &buf); err != nil {
|
||||
log.Printf("Failed to render setup template: %v", err)
|
||||
return c.HTML(http.StatusInternalServerError, "Failed to render setup page")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"bookhoard/internal/utils"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
@@ -16,6 +18,10 @@ type BookMatch struct {
|
||||
BookhoardUUID uuid.UUID `json:"bookhoard_uuid"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
MatchMethod string `json:"match_method"`
|
||||
// Display fields, filled during the family-lens pass in QueryBooks.
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
}
|
||||
|
||||
// BookQueryRequest represents a book query from a device
|
||||
@@ -24,7 +30,11 @@ type BookQueryRequest struct {
|
||||
SHA256 string `json:"sha256"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
// VisibleLibraryIDs scopes results to the requesting user's visible
|
||||
// libraries (family-lens, absent-from-results). Set server-side by the
|
||||
// handler — never from client input. nil keeps legacy callers unfiltered.
|
||||
VisibleLibraryIDs []string `json:"-"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
}
|
||||
|
||||
// BookQueryResponse represents the response to a book query
|
||||
@@ -106,6 +116,36 @@ func (s *BookMatchingService) QueryBooks(ctx context.Context, req *BookQueryRequ
|
||||
}
|
||||
}
|
||||
|
||||
// Family-lens: matches may only surface items from libraries the
|
||||
// requesting user can see — a hidden library's book simply never
|
||||
// matches (absent-from-results). Runs before determineAction so the
|
||||
// suggested action reflects the filtered set. nil (unset) keeps
|
||||
// legacy callers unfiltered.
|
||||
// Enrich + scope in one pass over the cascade hits: display fields come
|
||||
// from the resolved item, and when scoped the family-lens filter drops
|
||||
// matches from libraries the requester cannot see
|
||||
// (absent-from-results).
|
||||
scoped := req.VisibleLibraryIDs != nil
|
||||
allowed := make(map[string]bool, len(req.VisibleLibraryIDs))
|
||||
for _, id := range req.VisibleLibraryIDs {
|
||||
allowed[id] = true
|
||||
}
|
||||
filtered := matches[:0]
|
||||
for _, m := range matches {
|
||||
item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: m.MediaItemID, Valid: true})
|
||||
if err != nil || !item.LibraryID.Valid {
|
||||
continue // vanished between cascade and filter — drop
|
||||
}
|
||||
m.Title = item.Title
|
||||
m.Author = utils.PgTextString(item.Author)
|
||||
m.CoverImagePath = utils.PgTextString(item.CoverImagePath)
|
||||
if scoped && !allowed[uuid.UUID(item.LibraryID.Bytes).String()] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
matches = filtered
|
||||
|
||||
// Determine action
|
||||
action := s.determineAction(matches)
|
||||
|
||||
|
||||
@@ -38,7 +38,11 @@ var AllowedExtensions = map[string][]string{
|
||||
// All four decided breadth formats registered from day one (the
|
||||
// DOC/LIT doctrine: extensions are a deliberate list); only m4b
|
||||
// scans richly in Batch A — the chapter abstraction carries the rest.
|
||||
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac"},
|
||||
// .zip joins as the zip-of-audio book container (multi-file ingest);
|
||||
// comics libraries keep using .cbz so the two never collide. The
|
||||
// browser-playable lossless/next-gen containers join for Batch B
|
||||
// metadata parity (.wma deliberately absent: browsers can't play it).
|
||||
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav"},
|
||||
}
|
||||
|
||||
var MimeTypes = map[string]string{
|
||||
@@ -74,6 +78,15 @@ var MimeTypes = map[string]string{
|
||||
".m4a": "audio/mp4",
|
||||
".mp3": "audio/mpeg",
|
||||
".flac": "audio/flac",
|
||||
// Opus is registered as audio/ogg per RFC 7845 (Ogg encapsulation);
|
||||
// every browser <audio> element accepts it that way.
|
||||
".opus": "audio/ogg",
|
||||
".ogg": "audio/ogg",
|
||||
".oga": "audio/ogg",
|
||||
".aac": "audio/aac",
|
||||
".wav": "audio/wav",
|
||||
// Zip-of-audio book container (audiobooks libraries only).
|
||||
".zip": "application/zip",
|
||||
}
|
||||
|
||||
// GetLibraryTypes retrieves all available library types
|
||||
|
||||
@@ -140,6 +140,12 @@ type MediaScanner struct {
|
||||
newItems int
|
||||
errors int
|
||||
job *Job
|
||||
|
||||
// audioBookRoots tracks the folder-book roots (absolute paths)
|
||||
// already ingested during THIS scan walk, so per-track walk visits
|
||||
// skip instead of reprocessing. Reset at the top of ScanFolders; the
|
||||
// archive pass reuses it to keep book roots off the missing list.
|
||||
audioBookRoots map[string]bool
|
||||
}
|
||||
|
||||
// CalibreOPFMetadata represents intermediate parsed metadata from Calibre metadata.opf files
|
||||
@@ -419,6 +425,14 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder string) {
|
||||
}
|
||||
|
||||
func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
// Cancellation is checked at every stage boundary and inside the
|
||||
// walk callback: the in-flight file finishes, then the scan aborts
|
||||
// and reports context.Canceled (processJob classifies that as a
|
||||
// cancelled job, not a failed one).
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
if len(s.folders) == 0 {
|
||||
return fmt.Errorf("no folders set")
|
||||
}
|
||||
@@ -426,8 +440,12 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
s.totalFiles = 0
|
||||
s.newItems = 0
|
||||
s.errors = 0
|
||||
s.audioBookRoots = make(map[string]bool)
|
||||
|
||||
for _, folder := range s.folders {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
||||
if !d.IsDir() && s.isScannableFile(path) {
|
||||
s.totalFiles++
|
||||
@@ -445,6 +463,10 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
mediaFiles := 0
|
||||
|
||||
for _, folder := range s.folders {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
fmt.Printf("Scanning folder: %s\n", folder)
|
||||
|
||||
if _, err := os.Stat(folder); os.IsNotExist(err) {
|
||||
@@ -454,6 +476,10 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
}
|
||||
|
||||
err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
||||
if ctx.Err() != nil {
|
||||
return fs.SkipAll
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Error accessing path %s: %v\n", path, err)
|
||||
s.errors++
|
||||
@@ -480,6 +506,19 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-file audiobooks first: zip-of-audio books and
|
||||
// folder-of-tracks books ingest as whole units, and tracks
|
||||
// inside an already-ingested root skip entirely. Everything
|
||||
// else (lone audio files, all non-audio media) falls
|
||||
// through to the single-file path below.
|
||||
if handled, err := s.maybeProcessAudioBook(ctx, path); handled {
|
||||
if err != nil {
|
||||
fmt.Printf("Error processing audio book %s: %v\n", path, err)
|
||||
s.errors++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
wasNew, err := s.processMediaFile(ctx, path)
|
||||
if err != nil {
|
||||
fmt.Printf("Error processing media file %s: %v\n", path, err)
|
||||
@@ -502,6 +541,14 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
fmt.Printf("Scan completed: %d total files scanned, %d media files found, %d new items, %d errors\n",
|
||||
processedFiles, mediaFiles, s.newItems, s.errors)
|
||||
|
||||
// A cancelled scan must not run the archive lifecycle pass below:
|
||||
// it never visited every folder, so unvisited files would be
|
||||
// treated as missing and archived (and eventually purged). Abort
|
||||
// instead — cancelling never destroys library data.
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Archive lifecycle pass. Items whose files vanished from disk are
|
||||
// archived after two consecutive missing scans (reading history kept,
|
||||
// item hidden), and purged for good once archived older than the
|
||||
@@ -509,6 +556,11 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
// Every branch logs - the previous hard-delete cleanup failed silently
|
||||
// and left orphaned rows undetected.
|
||||
for _, folder := range s.folders {
|
||||
if ctx.Err() != nil {
|
||||
fmt.Printf("[ARCHIVE] Scan cancelled, stopping archive pass\n")
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -537,6 +589,14 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
continue // Skip to next folder 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.
|
||||
for root := range s.audioBookRoots {
|
||||
if strings.HasPrefix(root, folder) {
|
||||
scannedPaths[s.getRelativePath(root)] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range dbItems {
|
||||
if item.FilePath == "" || scannedPaths[item.FilePath] {
|
||||
continue // File present; unarchive is handled in processMediaFile
|
||||
@@ -795,16 +855,6 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
metadata = &MediaMetadata{}
|
||||
}
|
||||
|
||||
// Audiobooks: sidecar narrators/chapters + ffprobe facts into the
|
||||
// audio_metadata JSONB (sidecar-first, ffprobe-fills; the payload's
|
||||
// shape is the chapter abstraction every format writes).
|
||||
if isAudioFile(path) {
|
||||
if metadata.AudioMeta == nil {
|
||||
metadata.AudioMeta = readSidecarAudio(path)
|
||||
}
|
||||
s.enrichAudioMetadata(path, metadata)
|
||||
}
|
||||
|
||||
// Extract hash information during metadata extraction
|
||||
hashInfo, formatInfo, err := s.extractHashInfo(path)
|
||||
if err != nil {
|
||||
@@ -921,6 +971,21 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
}
|
||||
}
|
||||
|
||||
// Audiobooks: sidecar narrators/chapters + ffprobe facts (incl. the
|
||||
// full tag fallbacks) into the audio_metadata JSONB. Runs AFTER the
|
||||
// folder-structure pass so the precedence chain holds: sidecars →
|
||||
// folder structure → tags → filename. The payload's shape is the
|
||||
// chapter abstraction every format writes.
|
||||
if isAudioFile(path) {
|
||||
if metadata.AudioMeta == nil {
|
||||
metadata.AudioMeta = readSidecarAudio(path)
|
||||
}
|
||||
s.enrichAudioMetadata(path, metadata)
|
||||
if metadata.CoverPath == "" {
|
||||
s.applyEmbeddedCoverFallback(path, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback if still missing essential metadata
|
||||
if metadata.Title == "" {
|
||||
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
@@ -956,6 +1021,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
||||
Contributors: metadata.Contributors,
|
||||
ContributorsSearch: contributorsSearch,
|
||||
Narrators: nilToEmpty(metadata.Narrators),
|
||||
@@ -1086,6 +1152,16 @@ func (s *MediaScanner) applyEmbeddedCoverFallback(path string, metadata *MediaMe
|
||||
if coverPath, err := s.extractEPUBCover(path); err == nil && coverPath != "" {
|
||||
metadata.CoverPath = s.getRelativePath(coverPath)
|
||||
}
|
||||
default:
|
||||
// Audio containers (mp3 APIC, m4b/flac embedded picture): one
|
||||
// ffmpeg call, written as a "<file>.cover.jpg" sibling — the
|
||||
// same convention the EPUB path uses. Purely optional: absent
|
||||
// ffmpeg or no embedded art simply leaves the cover empty.
|
||||
if isAudioFile(path) && !strings.EqualFold(filepath.Ext(path), ".zip") {
|
||||
if cover := extractEmbeddedAudioCover(path, path); cover != "" {
|
||||
metadata.CoverPath = s.getRelativePath(cover)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1680,10 +1756,10 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
|
||||
}
|
||||
return metadata, nil
|
||||
default:
|
||||
// For other formats, return basic metadata
|
||||
return &MediaMetadata{
|
||||
Title: strings.TrimSuffix(filepath.Base(path), ext),
|
||||
}, nil
|
||||
// Audio containers and other unknown types: NO filename title
|
||||
// here — processMediaFile's final fallback applies the filename
|
||||
// LAST, after the audio tag-fallback chain has had its turn.
|
||||
return &MediaMetadata{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3161,20 +3237,29 @@ func countArchiveImages(filePath string) (int, error) {
|
||||
// from the existing row so rescans never clobber user customizations; only
|
||||
// reset-to-scanned-defaults (RescanMediaItem with reset) clears them.
|
||||
func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.MediaItems, path string) error {
|
||||
// Re-extract metadata for the update
|
||||
metadata, err := s.extractMetadata(path)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to extract metadata for force rescan %s: %v\n", path, err)
|
||||
metadata = &MediaMetadata{}
|
||||
}
|
||||
return s.updateMediaItemWithMetadata(ctx, existing, path, nil)
|
||||
}
|
||||
|
||||
// Rescans must RE-DERIVE audio facts, never preserve stale ones
|
||||
// (gotcha 39's principle applied to audio_metadata).
|
||||
if isAudioFile(path) {
|
||||
if metadata.AudioMeta == nil {
|
||||
metadata.AudioMeta = readSidecarAudio(path)
|
||||
// updateMediaItemWithMetadata is updateMediaItem with an optional
|
||||
// pre-extracted metadata set. Multi-file audiobooks arrive through
|
||||
// RescanMediaItem with book-level metadata (sidecars, folder structure,
|
||||
// first-track tags) because the generic per-file extraction would read
|
||||
// the book ROOT/CONTAINER as if it were the book file.
|
||||
func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing database.MediaItems, path string, preExtracted *MediaMetadata) error {
|
||||
var metadata *MediaMetadata
|
||||
if preExtracted != nil {
|
||||
metadata = preExtracted
|
||||
} else {
|
||||
// Re-extract metadata for the update
|
||||
extracted, err := s.extractMetadata(path)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to extract metadata for force rescan %s: %v\n", path, err)
|
||||
metadata = &MediaMetadata{}
|
||||
} else if extracted == nil {
|
||||
metadata = &MediaMetadata{}
|
||||
} else {
|
||||
metadata = extracted
|
||||
}
|
||||
s.enrichAudioMetadata(path, metadata)
|
||||
}
|
||||
|
||||
// Folder-structure fallback, mirroring the CREATE path
|
||||
@@ -3201,6 +3286,35 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
|
||||
}
|
||||
}
|
||||
|
||||
// Audiobooks (single-file): re-derive the audio facts + tag fallbacks
|
||||
// AFTER the folder-structure pass — the precedence chain (sidecars →
|
||||
// folder structure → tags → filename) must hold on rescans too.
|
||||
// Multi-file books arrive with preExtracted metadata already carrying
|
||||
// their enriched payload.
|
||||
// Rescans must RE-DERIVE audio facts, never preserve stale ones
|
||||
// (gotcha 39's principle applied to audio_metadata).
|
||||
if preExtracted == nil && isAudioFile(path) {
|
||||
if metadata.AudioMeta == nil {
|
||||
metadata.AudioMeta = readSidecarAudio(path)
|
||||
}
|
||||
s.enrichAudioMetadata(path, metadata)
|
||||
if metadata.CoverPath == "" {
|
||||
s.applyEmbeddedCoverFallback(path, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// Final filename fallback, mirroring processMediaFile's create path:
|
||||
// extractMetadata's default branch no longer pre-fills the filename
|
||||
// as title (embedded tags must outrank it), so the update path needs
|
||||
// the same last rung — a sidecar-less, tag-less book at a library
|
||||
// root (no folder structure to match) keeps its filename-derived
|
||||
// title instead of being blanked. Guarded to single-file updates:
|
||||
// multi-file representatives are sentinels/containers whose names
|
||||
// must never leak as titles.
|
||||
if preExtracted == nil && metadata.Title == "" {
|
||||
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
|
||||
// Normalize metadata fields
|
||||
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
|
||||
metadata.Tags = utils.NormalizeTags(metadata.Tags)
|
||||
@@ -3257,21 +3371,26 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
|
||||
utils.ApplyMetadataOverrides(¶ms, existing)
|
||||
params.MetadataOverrides = utils.MergeOverrides(existing.MetadataOverrides)
|
||||
|
||||
if _, err = s.db.UpdateMediaItem(ctx, params); err != nil {
|
||||
if _, err := s.db.UpdateMediaItem(ctx, params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Format flags are file-derived facts, not user metadata: refresh them on
|
||||
// every update so a force rescan (the admin's backfill tool) can
|
||||
// reclassify rows created before a classifyFormatGroup arm existed.
|
||||
// Mirrors the creation path above.
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
var epubIsFixedLayout bool
|
||||
if ext == ".epub" {
|
||||
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
|
||||
epubIsFixedLayout = fixedErr == nil && isFixed
|
||||
// Mirrors the creation path above. Multi-file audiobooks keep the
|
||||
// "audiobook" group: their representative path is a directory or a zip
|
||||
// container, which no classify arm could name.
|
||||
formatGroup, isReflowable, hasFixedLayout := "audiobook", false, false
|
||||
if preExtracted == nil || preExtracted.AudioMeta == nil || preExtracted.AudioMeta.Layout == "" {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
var epubIsFixedLayout bool
|
||||
if ext == ".epub" {
|
||||
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
|
||||
epubIsFixedLayout = fixedErr == nil && isFixed
|
||||
}
|
||||
formatGroup, isReflowable, hasFixedLayout = classifyFormatGroup(ext, epubIsFixedLayout)
|
||||
}
|
||||
formatGroup, isReflowable, hasFixedLayout := classifyFormatGroup(ext, epubIsFixedLayout)
|
||||
mimeType := s.getMimeType(path)
|
||||
if err := s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
|
||||
ID: existing.ID,
|
||||
@@ -3335,6 +3454,16 @@ func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.U
|
||||
return fmt.Errorf("failed to stat media file: %w", err)
|
||||
}
|
||||
|
||||
// Multi-file audiobooks (folder/zip layout) must NOT go through the
|
||||
// generic per-file updater: it would read the book ROOT directory (or
|
||||
// the zip container) as if it were the book file — deriving a filename
|
||||
// title and classifying format_group "unknown", which drops the item
|
||||
// out of the audio reader. The book-level path rebuilds the track
|
||||
// manifest and re-derives metadata from sidecars/tags instead.
|
||||
if item.FormatGroup == "audiobook" && audioLayoutOf(item.AudioMetadata) != "" {
|
||||
return s.rescanMultiFileAudioBook(ctx, item, fullPath)
|
||||
}
|
||||
|
||||
if err := s.updateMediaItem(ctx, item, fullPath); err != nil {
|
||||
return fmt.Errorf("failed to update media item: %w", err)
|
||||
}
|
||||
@@ -3980,6 +4109,8 @@ func (s *MediaScanner) detectFormatType(filePath string) string {
|
||||
return "pdf"
|
||||
case ".cbz", ".cbr", ".cb7", ".cbt":
|
||||
return "comic_archive"
|
||||
case ".m4b", ".m4a", ".mp3", ".flac", ".opus", ".ogg", ".oga", ".aac", ".wav":
|
||||
return "audiobook"
|
||||
case ".mobi":
|
||||
return "mobi"
|
||||
case ".azw", ".azw3":
|
||||
@@ -4010,7 +4141,7 @@ func classifyFormatGroup(ext string, epubIsFixedLayout bool) (formatGroup string
|
||||
return "fixed_layout", false, true
|
||||
case ".cbz", ".cbr", ".cb7", ".cbt":
|
||||
return "comic_archive", false, true
|
||||
case ".m4b", ".m4a", ".mp3", ".flac":
|
||||
case ".m4b", ".m4a", ".mp3", ".flac", ".opus", ".ogg", ".oga", ".aac", ".wav":
|
||||
// Audio is neither reflowable text nor fixed-layout pages; the
|
||||
// "audiobook" group routes readers to the audio player instead
|
||||
// of any document engine.
|
||||
|
||||
@@ -9,10 +9,17 @@ package services
|
||||
// until a rescan runs with ffprobe present).
|
||||
//
|
||||
// The chapters array written into media_items.audio_metadata IS the
|
||||
// format abstraction: embedded m4b chapters and (later) folder-as-book
|
||||
// files both produce {index, title, start, end} entries, so the locator
|
||||
// {chapter, offset_seconds} and the /chapters endpoint never learn which
|
||||
// file model produced them.
|
||||
// format abstraction: embedded m4b chapters, folder-as-book files and
|
||||
// zip-of-audio books all produce {index, title, start, end} entries, so
|
||||
// the locator {chapter, offset_seconds} and the /chapters endpoint never
|
||||
// learn which file model produced them.
|
||||
//
|
||||
// Multi-file books (layout "folder" | "zip", see
|
||||
// media_scanner_audio_multifile.go) additionally carry a tracks array:
|
||||
// one entry per playable file, with its global-seconds start/end and the
|
||||
// path the /audio/:trackIndex streaming endpoint resolves. Single-file
|
||||
// books leave layout and tracks empty — the <audio> element streams the
|
||||
// whole file exactly as before.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -33,6 +40,21 @@ type AudioChapter struct {
|
||||
End float64 `json:"end"`
|
||||
}
|
||||
|
||||
// AudioTrack is one playable file of a multi-file audiobook. Path is the
|
||||
// library-relative file path (layout "folder") or the in-zip entry name
|
||||
// (layout "zip"); the /audio/:trackIndex endpoint resolves it to bytes.
|
||||
// Start/End are cumulative global seconds across the book — the same
|
||||
// coordinate system chapters, bookmarks and audio_offset_seconds use.
|
||||
type AudioTrack struct {
|
||||
Index int `json:"index"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
DurationSeconds float64 `json:"duration_seconds,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// AudioMetadataPayload is the JSONB stored in media_items.audio_metadata.
|
||||
// Only the audio paths read it — ebook paths never do (the kobo_metadata
|
||||
// precedent).
|
||||
@@ -44,16 +66,33 @@ type AudioMetadataPayload struct {
|
||||
ASIN string `json:"asin,omitempty"`
|
||||
Abridged *bool `json:"abridged,omitempty"`
|
||||
Chapters []AudioChapter `json:"chapters,omitempty"`
|
||||
// Layout: "" = single file (the Batch A model), "folder" = a
|
||||
// directory of track files, "zip" = a zip archive of tracks.
|
||||
Layout string `json:"layout,omitempty"`
|
||||
// Tracks: one entry per playable file for multi-file layouts. Empty
|
||||
// for single-file books (the whole file IS the track).
|
||||
Tracks []AudioTrack `json:"tracks,omitempty"`
|
||||
}
|
||||
|
||||
func isAudioFile(path string) bool {
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".m4b", ".m4a", ".mp3", ".flac":
|
||||
case ".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isAudioTrackFile reports whether a path is a PLAYABLE track of a
|
||||
// multi-file book. Zips are book CONTAINERS (layout "zip"), never tracks
|
||||
// of a folder book — a nested zip inside a book root must not join the
|
||||
// track list, and pre-grouping one-track strays were never zips.
|
||||
func isAudioTrackFile(path string) bool {
|
||||
if strings.EqualFold(filepath.Ext(path), ".zip") {
|
||||
return false
|
||||
}
|
||||
return isAudioFile(path)
|
||||
}
|
||||
|
||||
// ffprobeAvailable is resolved once per scan process; a missing binary is
|
||||
// a degradation, not an error.
|
||||
var ffprobeAvailable = func() bool {
|
||||
@@ -61,10 +100,55 @@ var ffprobeAvailable = func() bool {
|
||||
return err == nil
|
||||
}()
|
||||
|
||||
// ffmpegAvailable mirrors ffprobeAvailable for the embedded-cover
|
||||
// extractor (ffprobe cannot write image streams; ffmpeg can).
|
||||
var ffmpegAvailable = func() bool {
|
||||
_, err := exec.LookPath("ffmpeg")
|
||||
return err == nil
|
||||
}()
|
||||
|
||||
// extractEmbeddedAudioCover pulls the embedded cover art (mp3 APIC /
|
||||
// m4b covr / flac picture) out of an audio file with one ffmpeg call —
|
||||
// the attached picture presents itself as the file's only video stream.
|
||||
// Writes outputBase+".cover.jpg" following the EPUB sidecar convention
|
||||
// ("<book>.cover.jpg"). Returns "" when ffmpeg is absent, the file
|
||||
// carries no picture, or anything fails — purely optional enrichment.
|
||||
func extractEmbeddedAudioCover(inputPath, outputBase string) string {
|
||||
if !ffmpegAvailable {
|
||||
return ""
|
||||
}
|
||||
outPath := outputBase + ".cover.jpg"
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-y", "-v", "error",
|
||||
"-i", inputPath,
|
||||
"-map", "0:v:0",
|
||||
"-frames:v", "1",
|
||||
outPath,
|
||||
)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
// "matches no streams" is the no-embedded-art case; stay quiet
|
||||
// about it, log anything unexpected.
|
||||
if len(out) > 0 && !strings.Contains(string(out), "matches no streams") {
|
||||
fmt.Printf("ffmpeg cover extraction note for %s: %s\n", inputPath, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if info, err := os.Stat(outPath); err != nil || info.Size() == 0 {
|
||||
_ = os.Remove(outPath)
|
||||
return ""
|
||||
}
|
||||
return outPath
|
||||
}
|
||||
|
||||
type ffprobeReport struct {
|
||||
Format struct {
|
||||
Duration flexFloat `json:"duration"`
|
||||
BitRate flexFloat `json:"bit_rate"`
|
||||
// Tags: ffprobe normalizes well-known atoms to lowercase keys but
|
||||
// passes freeform keys (Audible's `----:com.apple.iTunes:*`)
|
||||
// through verbatim — a map with case-insensitive lookup is the
|
||||
// only honest shape. See tag().
|
||||
Tags map[string]string `json:"tags"`
|
||||
} `json:"format"`
|
||||
Streams []struct {
|
||||
CodecName string `json:"codec_name"`
|
||||
@@ -134,6 +218,24 @@ func runFFProbe(path string) *ffprobeReport {
|
||||
return &report
|
||||
}
|
||||
|
||||
// tag returns the first non-empty ffprobe tag among the candidate keys,
|
||||
// matched case-insensitively and trimmed. Empty string when none match.
|
||||
func (r *ffprobeReport) tag(keys ...string) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
for k, v := range r.Format.Tags {
|
||||
if strings.EqualFold(k, key) {
|
||||
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// enrichAudioMetadata merges ffprobe facts into the (possibly sidecar-
|
||||
// seeded) audio payload, creating it when no sidecar existed. Sidecar
|
||||
// narrators/chapters win (curated); ffprobe fills duration, codec,
|
||||
@@ -192,6 +294,24 @@ func (s *MediaScanner) enrichAudioMetadata(path string, metadata *MediaMetadata)
|
||||
if payload.Abridged != nil {
|
||||
metadata.Abridged = *payload.Abridged
|
||||
}
|
||||
|
||||
// Tag fallbacks run LAST in the single-file chain (sidecars → folder
|
||||
// structure handled by the caller → tags → filename): they only fill
|
||||
// fields the earlier sources left empty.
|
||||
applyAudioTagFallbacks(metadata, report)
|
||||
|
||||
// CUE sheets are the last chapter source: sidecar chapters and
|
||||
// embedded container chapters (m4b) outrank them.
|
||||
if len(payload.Chapters) == 0 {
|
||||
if cueChapters := readCueChapters(path); len(cueChapters) > 0 {
|
||||
payload.Chapters = cueChapters
|
||||
if payload.DurationSeconds == 0 {
|
||||
if last := cueChapters[len(cueChapters)-1]; last.End > 0 {
|
||||
payload.DurationSeconds = last.End
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseSidecarAudioFields extracts the audio-only fields from a
|
||||
@@ -261,6 +381,103 @@ func marshalAudioMetadata(payload *AudioMetadataPayload) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
// applyAudioTagFallbacks fills EMPTY media metadata from the file's own
|
||||
// tags — the last rung of the ingestion precedence chain (curated
|
||||
// sidecars → folder structure → embedded tags → filename). Field map:
|
||||
//
|
||||
// album / album_artist / artist → title / author
|
||||
// composer → narrators (Audible's own
|
||||
// convention: the narrator lives in the composer tag)
|
||||
// grouping → series
|
||||
// ----:com.apple.iTunes:SERIES[-PART] → series + index (Libation
|
||||
// preserves these Audible freeform atoms)
|
||||
// ----: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
|
||||
//
|
||||
// Abridged is the one field where an explicit sidecar false must not be
|
||||
// overridden by a tag — the payload pointer marks sidecar presence.
|
||||
func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
|
||||
if metadata == nil || report == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if metadata.Title == "" {
|
||||
if album := report.tag("album"); album != "" {
|
||||
metadata.Title = album
|
||||
}
|
||||
}
|
||||
// Subtitle joins the title Calibre-style, mirroring the ABS sidecar
|
||||
// path — there is no separate subtitle column. Skip when the title
|
||||
// already contains it (tools sometimes pre-join).
|
||||
if subtitle := report.tag("----:com.apple.itunes:subtitle", "subtitle"); subtitle != "" && metadata.Title != "" &&
|
||||
!strings.Contains(strings.ToLower(metadata.Title), strings.ToLower(subtitle)) {
|
||||
metadata.Title = metadata.Title + ": " + subtitle
|
||||
}
|
||||
if metadata.Author == "" {
|
||||
if artist := report.tag("album_artist"); artist != "" {
|
||||
metadata.Author = artist
|
||||
} else if artist := report.tag("artist"); artist != "" {
|
||||
metadata.Author = artist
|
||||
}
|
||||
}
|
||||
if len(metadata.Narrators) == 0 {
|
||||
// Kept as ONE narrator string: composer values have no reliable
|
||||
// multi-narrator delimiter, and splitting "Vance, Simon" would
|
||||
// manufacture two fake people. Admins can edit the column.
|
||||
if composer := report.tag("composer"); composer != "" {
|
||||
metadata.Narrators = []string{composer}
|
||||
if metadata.AudioMeta != nil && len(metadata.AudioMeta.Narrators) == 0 {
|
||||
metadata.AudioMeta.Narrators = []string{composer}
|
||||
}
|
||||
}
|
||||
}
|
||||
if metadata.Series == "" {
|
||||
if series := report.tag("grouping", "----:com.apple.itunes:series"); series != "" {
|
||||
metadata.Series = series
|
||||
if part := report.tag("----:com.apple.itunes:series-part"); part != "" {
|
||||
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 32); err == nil {
|
||||
metadata.SeriesNumber = int32(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if metadata.ASIN == "" {
|
||||
if asin := report.tag("----:com.apple.itunes:asin", "asin"); asin != "" {
|
||||
metadata.ASIN = asin
|
||||
if metadata.AudioMeta != nil && metadata.AudioMeta.ASIN == "" {
|
||||
metadata.AudioMeta.ASIN = asin
|
||||
}
|
||||
}
|
||||
}
|
||||
if metadata.AudioMeta == nil || metadata.AudioMeta.Abridged == nil {
|
||||
if !metadata.Abridged {
|
||||
switch strings.ToLower(report.tag("----:com.apple.itunes:abridged")) {
|
||||
case "1", "true", "yes":
|
||||
metadata.Abridged = true
|
||||
if metadata.AudioMeta != nil {
|
||||
abridged := true
|
||||
metadata.AudioMeta.Abridged = &abridged
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if metadata.PublishDate.IsZero() {
|
||||
if date := report.tag("date"); date != "" {
|
||||
for _, layout := range []string{"2006-01-02", "2006-01", "2006"} {
|
||||
if t, err := time.Parse(layout, strings.TrimSpace(date)); err == nil {
|
||||
metadata.PublishDate = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if metadata.Genre == "" {
|
||||
metadata.Genre = report.tag("genre")
|
||||
}
|
||||
}
|
||||
|
||||
// audioDurationLabel renders the duration for logs/detail surfaces.
|
||||
func audioDurationLabel(seconds float64) string {
|
||||
if seconds <= 0 {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package services
|
||||
|
||||
// CUE sheet support: a `.cue` sidecar describing the chapter marks of a
|
||||
// single big audio file (the classic CD-rip shape: one flac/mp3 + one
|
||||
// cue). CUE is the LOWEST-priority chapter source — curated sidecar
|
||||
// chapters and embedded m4b chapters outrank it — and a purely optional
|
||||
// one: no cue, no chapters, no error.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cueFramesPerSecond is the CD framing CUE INDEX timestamps use
|
||||
// (MM:SS:FF with 75 frames per second).
|
||||
const cueFramesPerSecond = 75.0
|
||||
|
||||
// cueTimeToSeconds converts a CUE INDEX timestamp ("03:25:12") to
|
||||
// seconds. Minutes may exceed 59 and hours are folded into the minutes
|
||||
// field per the spec (MM:SS:FF, MM unbounded).
|
||||
func cueTimeToSeconds(v string) float64 {
|
||||
parts := strings.Split(strings.TrimSpace(v), ":")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
minutes, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
||||
seconds, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
frames, err3 := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return 0
|
||||
}
|
||||
return minutes*60 + seconds + frames/cueFramesPerSecond
|
||||
}
|
||||
|
||||
// readCueChapters finds a CUE sheet describing the given audio file and
|
||||
// returns its tracks as chapters (global seconds, ends derived from the
|
||||
// next start; the last end stays 0 = unknown). A cue matches when its
|
||||
// FILE directive names the audio file (basename, case-insensitive), or
|
||||
// — for single-cue directories — when the directory holds exactly one.
|
||||
func readCueChapters(path string) []AudioChapter {
|
||||
dir := filepath.Dir(path)
|
||||
base := strings.ToLower(filepath.Base(path))
|
||||
|
||||
cuePaths, err := filepath.Glob(filepath.Join(dir, "*.cue"))
|
||||
if err != nil || len(cuePaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var matched string
|
||||
for _, cuePath := range cuePaths {
|
||||
if referencedByCue(cuePath, base) {
|
||||
matched = cuePath
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == "" && len(cuePaths) == 1 {
|
||||
// Single-cue directory: the cue almost certainly describes the
|
||||
// only audio file next to it.
|
||||
matched = cuePaths[0]
|
||||
}
|
||||
if matched == "" {
|
||||
return nil
|
||||
}
|
||||
return parseCueSheet(matched, base)
|
||||
}
|
||||
|
||||
// referencedByCue reports whether the cue's FILE directive names the
|
||||
// given audio basename.
|
||||
func referencedByCue(cuePath, audioBase string) bool {
|
||||
f, err := os.Open(cuePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if len(line) >= 4 && strings.EqualFold(line[:4], "FILE") {
|
||||
rest := strings.TrimSpace(line[4:])
|
||||
rest = strings.Trim(rest, "\"")
|
||||
// FILE "name" WAVE / FILE name MP3 — drop the type word.
|
||||
if idx := strings.LastIndexAny(rest, " \t"); idx != -1 {
|
||||
rest = strings.TrimSpace(rest[:idx])
|
||||
}
|
||||
rest = strings.Trim(rest, "\"")
|
||||
return strings.EqualFold(filepath.Base(rest), audioBase)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseCueSheet extracts TRACK/TITLE/INDEX 01 entries as chapters.
|
||||
// INDEX timestamps are absolute from the file start per the spec; the
|
||||
// pregap (INDEX 00) is ignored. Multi-file cues contribute only the
|
||||
// TRACK blocks under the FILE directive naming our audio file (or all
|
||||
// blocks when the cue declares no FILE at all).
|
||||
func parseCueSheet(cuePath, audioBase string) []AudioChapter {
|
||||
f, err := os.Open(cuePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
var chapters []AudioChapter
|
||||
var current *AudioChapter
|
||||
title := ""
|
||||
inOurFile := true
|
||||
|
||||
flush := func() {
|
||||
if current != nil && current.Start >= 0 {
|
||||
current.Title = strings.TrimSpace(title)
|
||||
if current.Title == "" {
|
||||
current.Title = "Chapter " + strconv.Itoa(current.Index+1)
|
||||
}
|
||||
if n := len(chapters); n > 0 && current.Start >= chapters[n-1].Start {
|
||||
chapters[n-1].End = current.Start
|
||||
}
|
||||
chapters = append(chapters, *current)
|
||||
}
|
||||
current = nil
|
||||
title = ""
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
upper := strings.ToUpper(line)
|
||||
switch {
|
||||
case strings.HasPrefix(upper, "FILE"):
|
||||
flush()
|
||||
rest := strings.Trim(strings.TrimSpace(line[4:]), "\"")
|
||||
if idx := strings.LastIndexAny(rest, " \t"); idx != -1 {
|
||||
rest = strings.TrimSpace(rest[:idx])
|
||||
}
|
||||
rest = strings.Trim(rest, "\"")
|
||||
inOurFile = strings.EqualFold(filepath.Base(rest), audioBase)
|
||||
case strings.HasPrefix(upper, "TRACK"):
|
||||
flush()
|
||||
if !inOurFile {
|
||||
continue
|
||||
}
|
||||
current = &AudioChapter{Index: len(chapters)}
|
||||
if fields := strings.Fields(line); len(fields) >= 2 {
|
||||
if n, err := strconv.Atoi(fields[1]); err == nil && n > 0 {
|
||||
current.Index = n - 1
|
||||
}
|
||||
}
|
||||
case strings.HasPrefix(upper, "TITLE") && current != nil:
|
||||
if q1 := strings.Index(line, "\""); q1 != -1 {
|
||||
if q2 := strings.LastIndex(line, "\""); q2 > q1 {
|
||||
title = line[q1+1 : q2]
|
||||
}
|
||||
}
|
||||
case strings.HasPrefix(upper, "INDEX"):
|
||||
if current == nil || !inOurFile {
|
||||
continue
|
||||
}
|
||||
if fields := strings.Fields(line); len(fields) >= 3 && fields[1] == "01" {
|
||||
current.Start = cueTimeToSeconds(fields[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
flush()
|
||||
|
||||
if len(chapters) == 0 {
|
||||
return nil
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package services
|
||||
|
||||
// Unit tests for the exhaustive audiobook metadata ingestion: CUE
|
||||
// sheets, m3u playlists, tag fallbacks (incl. Audible freeform atoms)
|
||||
// and tag-based track ordering.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCueTimeToSeconds(t *testing.T) {
|
||||
cases := map[string]float64{
|
||||
"00:00:00": 0,
|
||||
"01:00:00": 60,
|
||||
"03:25:12": 3*60 + 25 + 12.0/75.0,
|
||||
"75:00:37": 75*60 + 37.0/75.0, // minutes exceed 59 per spec
|
||||
"not-a-time": 0,
|
||||
"1:2": 0,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := cueTimeToSeconds(in); (got-want) > 0.001 || (want-got) > 0.001 {
|
||||
t.Errorf("cueTimeToSeconds(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCueSheet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cuePath := filepath.Join(dir, "book.cue")
|
||||
cue := `TITLE "Cue Book"
|
||||
FILE "book.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Opening"
|
||||
INDEX 01 00:00:00
|
||||
TRACK 02 AUDIO
|
||||
TITLE "The Middle Bit"
|
||||
INDEX 01 00:01:30
|
||||
TRACK 03 AUDIO
|
||||
TITLE "Closing"
|
||||
INDEX 01 00:03:00
|
||||
FILE "other.flac" WAVE
|
||||
TRACK 01 AUDIO
|
||||
TITLE "Foreign Track"
|
||||
INDEX 01 00:00:00
|
||||
`
|
||||
if err := os.WriteFile(cuePath, []byte(cue), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chapters := parseCueSheet(cuePath, "book.flac")
|
||||
if len(chapters) != 3 {
|
||||
t.Fatalf("chapters = %d (%+v), want 3 (foreign FILE block excluded)", len(chapters), chapters)
|
||||
}
|
||||
if chapters[0].Title != "Opening" || chapters[0].Start != 0 {
|
||||
t.Fatalf("first = %+v", chapters[0])
|
||||
}
|
||||
// MM:SS:FF: "00:01:30" = 1 second + 30/75 frames = 1.4s
|
||||
if chapters[1].Start != 1.4 || chapters[1].End != 3.0 {
|
||||
t.Fatalf("second = %+v", chapters[1])
|
||||
}
|
||||
if chapters[2].End != 0 {
|
||||
t.Fatalf("last end must stay 0 (unknown): %+v", chapters[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCueChaptersMatched(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cue := "FILE \"audio.mp3\" WAVE\n TRACK 01 AUDIO\n TITLE \"One\"\n INDEX 01 00:00:00\n TRACK 02 AUDIO\n TITLE \"Two\"\n INDEX 01 00:00:10\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "audio.cue"), []byte(cue), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chapters := readCueChapters(filepath.Join(dir, "audio.mp3"))
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("chapters = %d", len(chapters))
|
||||
}
|
||||
|
||||
// No cue at all → nil, never an error.
|
||||
if chapters := readCueChapters(filepath.Join(dir, "other.mp3")); chapters != nil {
|
||||
t.Fatalf("expected nil for missing cue, got %+v", chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAndApplyPlaylistHint(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
playlist := "#EXTM3U\n#EXTINF:120,Chapter Zero\n02_Second.mp3\n#EXTINF:60,Chapter One\n./01_First.mp3\nmissing.mp3\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "book.m3u"), []byte(playlist), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Exactly one playlist → hint; two → none.
|
||||
hint := findPlaylistHint(dir)
|
||||
if hint == nil {
|
||||
t.Fatal("expected hint")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "other.m3u8"), []byte("#EXTM3U\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if findPlaylistHint(dir) != nil {
|
||||
t.Fatal("multiple playlists must produce no hint")
|
||||
}
|
||||
|
||||
abs := []string{
|
||||
filepath.Join(dir, "01_First.mp3"),
|
||||
filepath.Join(dir, "02_Second.mp3"),
|
||||
filepath.Join(dir, "10_Later.mp3"), // not in playlist → appended last
|
||||
}
|
||||
ordered := hint.reorder(abs)
|
||||
if len(ordered) != 3 || ordered[0] != abs[1] || ordered[1] != abs[0] || ordered[2] != abs[2] {
|
||||
t.Fatalf("playlist order not applied: %v", ordered)
|
||||
}
|
||||
if got := hint.titleFor(abs[0]); got != "Chapter One" {
|
||||
t.Fatalf("titleFor(./01_First.mp3) = %q", got)
|
||||
}
|
||||
if got := hint.titleFor(abs[1]); got != "Chapter Zero" {
|
||||
t.Fatalf("titleFor(02_Second.mp3) = %q", got)
|
||||
}
|
||||
if got := hint.titleFor(abs[2]); got != "" {
|
||||
t.Fatalf("unlisted track must have no title, got %q", got)
|
||||
}
|
||||
if h := (*playlistHint)(nil); h.titleFor(abs[0]) != "" {
|
||||
t.Fatal("nil hint must be safe")
|
||||
}
|
||||
}
|
||||
|
||||
func syntheticReport(tags map[string]string) *ffprobeReport {
|
||||
r := &ffprobeReport{}
|
||||
r.Format.Tags = tags
|
||||
return r
|
||||
}
|
||||
|
||||
func TestApplyAudioTagFallbacks(t *testing.T) {
|
||||
t.Run("full fill", func(t *testing.T) {
|
||||
meta := &MediaMetadata{}
|
||||
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
|
||||
"album": "The Tagged Album",
|
||||
"album_artist": "Tag Author",
|
||||
"artist": "Wrong Author",
|
||||
"composer": "Tag Narrator",
|
||||
"grouping": "Tag Series",
|
||||
"genre": "Audiobook",
|
||||
"date": "2020",
|
||||
"----:com.apple.iTunes:SUBTITLE": "Or, A Life",
|
||||
"----:com.apple.iTunes:SERIES": "Freeform Series",
|
||||
"----:com.apple.iTunes:SERIES-PART": "4",
|
||||
"----:com.apple.iTunes:ASIN": "B0ASIN1234",
|
||||
"----:com.apple.iTunes:ABRIDGED": "1",
|
||||
}))
|
||||
if meta.Title != "The Tagged Album: Or, A Life" {
|
||||
t.Fatalf("title = %q", meta.Title)
|
||||
}
|
||||
if meta.Author != "Tag Author" {
|
||||
t.Fatalf("author = %q", meta.Author)
|
||||
}
|
||||
if len(meta.Narrators) != 1 || meta.Narrators[0] != "Tag Narrator" {
|
||||
t.Fatalf("narrators = %v", meta.Narrators)
|
||||
}
|
||||
// grouping outranks freeform SERIES for the series name only
|
||||
// because it is checked first; both are tag-level fallbacks.
|
||||
if meta.Series == "" {
|
||||
t.Fatal("series not filled from tags")
|
||||
}
|
||||
if meta.SeriesNumber != 4 {
|
||||
t.Fatalf("series number = %d", meta.SeriesNumber)
|
||||
}
|
||||
if meta.ASIN != "B0ASIN1234" {
|
||||
t.Fatalf("asin = %q", meta.ASIN)
|
||||
}
|
||||
if !meta.Abridged {
|
||||
t.Fatal("abridged not filled")
|
||||
}
|
||||
if meta.PublishDate.Year() != 2020 {
|
||||
t.Fatalf("publish date = %v", meta.PublishDate)
|
||||
}
|
||||
if meta.Genre != "Audiobook" {
|
||||
t.Fatalf("genre = %q", meta.Genre)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fills only empty fields", func(t *testing.T) {
|
||||
meta := &MediaMetadata{Title: "Sidecar Title", Author: "Sidecar Author", Narrators: []string{"Sidecar Narrator"}}
|
||||
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
|
||||
"album": "Tag Album", "artist": "Tag Artist", "composer": "Tag Narrator",
|
||||
}))
|
||||
if meta.Title != "Sidecar Title" || meta.Author != "Sidecar Author" {
|
||||
t.Fatalf("tags must not override earlier sources: %+v", meta)
|
||||
}
|
||||
if len(meta.Narrators) != 1 || meta.Narrators[0] != "Sidecar Narrator" {
|
||||
t.Fatalf("narrators = %v", meta.Narrators)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit sidecar false survives abridged tag", func(t *testing.T) {
|
||||
abridgedFalse := false
|
||||
meta := &MediaMetadata{AudioMeta: &AudioMetadataPayload{Abridged: &abridgedFalse}}
|
||||
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
|
||||
"----:com.apple.iTunes:ABRIDGED": "1",
|
||||
}))
|
||||
if meta.Abridged {
|
||||
t.Fatal("explicit sidecar false must not be overridden")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil report is a no-op", func(t *testing.T) {
|
||||
meta := &MediaMetadata{}
|
||||
applyAudioTagFallbacks(meta, nil)
|
||||
if meta.Title != "" {
|
||||
t.Fatal("nil report must change nothing")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseTagOrdinal(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
"2": 2,
|
||||
"3/9": 3,
|
||||
" 12 ": 12,
|
||||
"": 0,
|
||||
"x": 0,
|
||||
"0": 0,
|
||||
"2/": 2,
|
||||
"-1": 0,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := parseTagOrdinal(in); got != want {
|
||||
t.Errorf("parseTagOrdinal(%q) = %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderTracksByTags(t *testing.T) {
|
||||
mk := func(disc, track int, rel string) audioTrackInfo {
|
||||
return audioTrackInfo{disc: disc, trackNum: track, relPath: rel}
|
||||
}
|
||||
|
||||
t.Run("tag order wins when complete", func(t *testing.T) {
|
||||
probed := []audioTrackInfo{
|
||||
mk(2, 1, "b/01.mp3"), mk(1, 2, "a/02.mp3"), mk(1, 10, "a/10.mp3"),
|
||||
}
|
||||
orderTracksByTags(probed)
|
||||
want := []string{"a/02.mp3", "a/10.mp3", "b/01.mp3"}
|
||||
for i, tr := range probed {
|
||||
if tr.relPath != want[i] {
|
||||
t.Fatalf("order = %v", probed)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("incomplete coverage keeps natural order", func(t *testing.T) {
|
||||
probed := []audioTrackInfo{mk(1, 1, "a/01.mp3"), mk(0, 0, "a/02.mp3")}
|
||||
orderTracksByTags(probed)
|
||||
if probed[0].relPath != "a/01.mp3" || probed[1].relPath != "a/02.mp3" {
|
||||
t.Fatalf("order = %v", probed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTagLookupCaseInsensitive(t *testing.T) {
|
||||
r := syntheticReport(map[string]string{
|
||||
"ALBUM": "Cased Album",
|
||||
"----:com.apple.iTunes:ASIN": "B0CASE",
|
||||
})
|
||||
if got := r.tag("album"); got != "Cased Album" {
|
||||
t.Fatalf("tag(album) = %q", got)
|
||||
}
|
||||
if got := r.tag("album", "title"); got != "Cased Album" {
|
||||
t.Fatalf("multi-key lookup = %q", got)
|
||||
}
|
||||
if got := r.tag("----:COM.APPLE.ITUNES:asin"); !strings.EqualFold(got, "B0CASE") {
|
||||
t.Fatalf("freeform case-insensitive lookup = %q", got)
|
||||
}
|
||||
if got := r.tag("missing"); got != "" {
|
||||
t.Fatalf("missing key = %q", got)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,203 @@
|
||||
package services
|
||||
|
||||
// Unit tests for the multi-file audiobook grouping/manifest helpers
|
||||
// (media_scanner_audio_multifile.go). Pure filesystem logic — no DB.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNaturalLess(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
less bool
|
||||
}{
|
||||
{"01 Track.mp3", "02 Track.mp3", true},
|
||||
{"02 Track.mp3", "10 Track.mp3", true}, // numeric, not lexicographic
|
||||
{"10 Track.mp3", "9 Track.mp3", false},
|
||||
{"Track 2", "Track 10", true},
|
||||
{"abc", "abd", true},
|
||||
{"01", "1", false}, // leading zeros compare equal → strict-less false
|
||||
{"a1b", "a1c", true},
|
||||
{"Disc 1", "Disc 2", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := naturalLess(tc.a, tc.b); got != tc.less {
|
||||
t.Errorf("naturalLess(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.less)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortTracksNatural(t *testing.T) {
|
||||
in := []string{"/x/10.mp3", "/x/2.mp3", "/x/1.mp3"}
|
||||
sortTracksNatural(in)
|
||||
want := []string{"/x/1.mp3", "/x/2.mp3", "/x/10.mp3"}
|
||||
for i := range want {
|
||||
if in[i] != want[i] {
|
||||
t.Fatalf("sorted = %v, want %v", in, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeDummyTrack creates a file with plausible audio bytes (never probed
|
||||
// in these tests — only structure matters).
|
||||
func writeDummyTrack(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("ID3 fake audio bytes"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookRootForTrackFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
// Layout:
|
||||
// root/Author/Title/01.mp3, 02.mp3 → one book at Title
|
||||
// root/Author/Parts/CD1/01.mp3, CD2/01.mp3 → one book at Parts
|
||||
// root/Author/Lone/book.mp3 → no folder book
|
||||
writeDummyTrack(t, filepath.Join(root, "Author", "Title", "01.mp3"))
|
||||
writeDummyTrack(t, filepath.Join(root, "Author", "Title", "02.mp3"))
|
||||
writeDummyTrack(t, filepath.Join(root, "Author", "Parts", "CD1", "01.mp3"))
|
||||
writeDummyTrack(t, filepath.Join(root, "Author", "Parts", "CD2", "01.mp3"))
|
||||
writeDummyTrack(t, filepath.Join(root, "Author", "Lone", "book.mp3"))
|
||||
|
||||
t.Run("simple track folder", func(t *testing.T) {
|
||||
got, tracks, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Title", "02.mp3"), root)
|
||||
if !ok {
|
||||
t.Fatalf("expected folder book, got none")
|
||||
}
|
||||
if got != filepath.Join(root, "Author", "Title") {
|
||||
t.Fatalf("root = %q", got)
|
||||
}
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("tracks = %v", tracks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disc folders merge into parent", func(t *testing.T) {
|
||||
got, tracks, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Parts", "CD1", "01.mp3"), root)
|
||||
if !ok {
|
||||
t.Fatalf("expected folder book")
|
||||
}
|
||||
if got != filepath.Join(root, "Author", "Parts") {
|
||||
t.Fatalf("root = %q (disc folders must merge up)", got)
|
||||
}
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("tracks = %v", tracks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lone file is not a folder book", func(t *testing.T) {
|
||||
_, _, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Lone", "book.mp3"), root)
|
||||
if ok {
|
||||
t.Fatalf("single track must not form a folder book")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sibling books never merge at author level", func(t *testing.T) {
|
||||
writeDummyTrack(t, filepath.Join(root, "Author", "BookB", "01.mp3"))
|
||||
writeDummyTrack(t, filepath.Join(root, "Author", "BookB", "02.mp3"))
|
||||
got, _, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Title", "01.mp3"), root)
|
||||
if !ok || got != filepath.Join(root, "Author", "Title") {
|
||||
t.Fatalf("root = %q, ok = %v; Title/BookB must stay separate", got, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildMultiFileAudioPayload(t *testing.T) {
|
||||
tracks := []audioTrackInfo{
|
||||
{relPath: "Author/Title/01.mp3", title: "Chapter One", duration: 100, size: 10},
|
||||
{relPath: "Author/Title/02.mp3", title: "Chapter Two", duration: 50, size: 5},
|
||||
}
|
||||
payload := buildMultiFileAudioPayload("folder", nil, tracks)
|
||||
if payload.Layout != "folder" {
|
||||
t.Fatalf("layout = %q", payload.Layout)
|
||||
}
|
||||
if payload.DurationSeconds != 150 {
|
||||
t.Fatalf("duration = %v", payload.DurationSeconds)
|
||||
}
|
||||
if len(payload.Tracks) != 2 {
|
||||
t.Fatalf("tracks = %d", len(payload.Tracks))
|
||||
}
|
||||
if payload.Tracks[1].Start != 100 || payload.Tracks[1].End != 150 {
|
||||
t.Fatalf("cumulative bounds wrong: %+v", payload.Tracks[1])
|
||||
}
|
||||
// No sidecar chapters: tracks ARE the chapters.
|
||||
if len(payload.Chapters) != 2 || payload.Chapters[0].Start != 0 || payload.Chapters[1].Start != 100 {
|
||||
t.Fatalf("chapters = %+v", payload.Chapters)
|
||||
}
|
||||
|
||||
// Curated sidecar chapters keep priority; tracks still carry bounds.
|
||||
sidecar := &AudioMetadataPayload{
|
||||
Chapters: []AudioChapter{{Index: 0, Title: "Part I", Start: 0, End: 150}},
|
||||
}
|
||||
payload2 := buildMultiFileAudioPayload("zip", sidecar, tracks)
|
||||
if len(payload2.Chapters) != 1 || payload2.Chapters[0].Title != "Part I" {
|
||||
t.Fatalf("sidecar chapters must win: %+v", payload2.Chapters)
|
||||
}
|
||||
if len(payload2.Tracks) != 2 {
|
||||
t.Fatalf("tracks lost: %+v", payload2.Tracks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipAudioEntries(t *testing.T) {
|
||||
zipPath := filepath.Join(t.TempDir(), "book.zip")
|
||||
f, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(f)
|
||||
for _, name := range []string{
|
||||
"__MACOSX/._junk.mp3",
|
||||
".hidden.mp3",
|
||||
"02 second.mp3",
|
||||
"10 tenth.mp3",
|
||||
"01 first.mp3",
|
||||
"cover.jpg",
|
||||
"notes.txt",
|
||||
} {
|
||||
if _, err := w.Create(name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
entries, err := zipAudioEntries(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"01 first.mp3", "02 second.mp3", "10 tenth.mp3"}
|
||||
if len(entries) != len(want) {
|
||||
t.Fatalf("entries = %v", entries)
|
||||
}
|
||||
for i, e := range entries {
|
||||
if e.name != want[i] {
|
||||
t.Fatalf("entry[%d] = %q, want %q", i, e.name, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanTrackTitle(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/x/01_Genesis001.mp3": "Genesis001",
|
||||
"/x/01 - Track.mp3": "Track",
|
||||
"/x/12_.Intro.mp3": "Intro",
|
||||
"/x/Chapter 3.mp3": "Chapter 3",
|
||||
"/x/JustTitle.m4b": "JustTitle",
|
||||
"/x/01.mp3": "01", // number-only name: number stays the title
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := cleanTrackTitle(in); got != want {
|
||||
t.Errorf("cleanTrackTitle(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
-25
@@ -4,6 +4,7 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -27,6 +28,13 @@ const (
|
||||
JobStatusCancelled JobStatus = "cancelled"
|
||||
)
|
||||
|
||||
// Sentinel errors surfaced by CancelJob so HTTP handlers can map them
|
||||
// to 404/409 without string matching.
|
||||
var (
|
||||
ErrJobNotFound = errors.New("job not found")
|
||||
ErrJobNotCancellable = errors.New("job cannot be cancelled")
|
||||
)
|
||||
|
||||
type JobType string
|
||||
|
||||
const (
|
||||
@@ -45,11 +53,29 @@ var WorkerInstance *Worker
|
||||
func (w *Worker) Enqueue(job *Job) {
|
||||
select {
|
||||
case w.jobQueue <- job:
|
||||
w.recordPending(job)
|
||||
default:
|
||||
fmt.Printf("Worker queue full, rejecting job: %s\n", job.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// recordPending registers a queued job's pending result so the status
|
||||
// endpoint and GetActiveScanJobs can report jobs still waiting for a
|
||||
// worker, and so CancelJob can cancel them before execution starts.
|
||||
// The entry is replaced when processJob dequeues the job.
|
||||
func (w *Worker) recordPending(job *Job) {
|
||||
libID, _ := job.Params["library_id"].(string)
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if _, exists := w.results[job.ID]; !exists {
|
||||
w.results[job.ID] = &JobResult{
|
||||
JobID: job.ID,
|
||||
Status: JobStatusPending,
|
||||
LibraryID: libID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
ID string
|
||||
Type JobType
|
||||
@@ -146,8 +172,14 @@ type AnalyticsJobResult struct {
|
||||
ReportPath string `json:"report_path"`
|
||||
}
|
||||
type Worker struct {
|
||||
jobQueue chan *Job
|
||||
results map[string]*JobResult
|
||||
jobQueue chan *Job
|
||||
results map[string]*JobResult
|
||||
// cancels maps a running job's id to its per-job context cancel
|
||||
// func, registered by processJob and removed at completion. Lets
|
||||
// CancelJob stop a running job without disturbing siblings (all
|
||||
// manual scans used to share the handler's process-lifetime
|
||||
// context, so no targeted cancellation was possible).
|
||||
cancels map[string]context.CancelFunc
|
||||
connManager *wsync.ConnectionManager
|
||||
mu sync.RWMutex
|
||||
wg sync.WaitGroup
|
||||
@@ -192,6 +224,7 @@ func NewWorkerWithConfig(numWorkers, queueCap int, connManager *wsync.Connection
|
||||
w := &Worker{
|
||||
jobQueue: make(chan *Job, queueCap),
|
||||
results: make(map[string]*JobResult),
|
||||
cancels: make(map[string]context.CancelFunc),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
@@ -243,22 +276,40 @@ func (w *Worker) worker() {
|
||||
func (w *Worker) processJob(job *Job) {
|
||||
libID, _ := job.Params["library_id"].(string)
|
||||
w.mu.Lock()
|
||||
// A job cancelled while queued must never execute: honour the
|
||||
// pending-cancel marker and leave its result untouched.
|
||||
if result, exists := w.results[job.ID]; exists && result.Status == JobStatusCancelled {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.results[job.ID] = &JobResult{
|
||||
JobID: job.ID,
|
||||
Status: JobStatusRunning,
|
||||
LibraryID: libID,
|
||||
}
|
||||
|
||||
// Derive a per-job context so cancelling this job (CancelJob) stops
|
||||
// only this job. The parent stays whatever the caller passed (the
|
||||
// handler's context for manual scans, Background for auto-scans).
|
||||
parent := job.Context
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
jobCtx, cancel := context.WithCancel(parent)
|
||||
job.Context = jobCtx
|
||||
w.cancels[job.ID] = cancel
|
||||
w.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
w.mu.Lock()
|
||||
delete(w.cancels, job.ID)
|
||||
w.mu.Unlock()
|
||||
cancel()
|
||||
}()
|
||||
|
||||
started := time.Now()
|
||||
job.StartedAt = &started
|
||||
|
||||
w.mu.Lock()
|
||||
if result, exists := w.results[job.ID]; exists {
|
||||
result.Status = JobStatusRunning
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
var err error
|
||||
var result interface{}
|
||||
|
||||
@@ -318,27 +369,43 @@ func (w *Worker) processJob(job *Job) {
|
||||
}
|
||||
}
|
||||
|
||||
// A cancelled job stopped mid-flight: keep its last real progress
|
||||
// instead of claiming 100%, and drop the meaningless "context
|
||||
// canceled" processing error.
|
||||
finalProgress := 1.0
|
||||
finalError := ""
|
||||
if status == JobStatusCancelled {
|
||||
finalError = ""
|
||||
if old, exists := w.results[job.ID]; exists {
|
||||
finalProgress = old.Progress
|
||||
}
|
||||
} else if err != nil {
|
||||
finalError = err.Error()
|
||||
}
|
||||
|
||||
w.results[job.ID] = &JobResult{
|
||||
JobID: job.ID,
|
||||
Status: status,
|
||||
LibraryID: libID,
|
||||
Error: func() string {
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}(),
|
||||
Result: result,
|
||||
Progress: 1.0,
|
||||
Error: finalError,
|
||||
Result: result,
|
||||
Progress: finalProgress,
|
||||
|
||||
FilesScanned: filesScanned,
|
||||
NewItems: newItems,
|
||||
Errors: errors,
|
||||
}
|
||||
|
||||
if job.Type == JobTypeScan && w.connManager != nil && job.UserID != "" {
|
||||
// Cancelled scans announce scan_cancelled so clients reset
|
||||
// their scanning indicators; a "scan_complete" would read as
|
||||
// a finished scan.
|
||||
msgType := wsync.MessageTypeScanComplete
|
||||
if status == JobStatusCancelled {
|
||||
msgType = wsync.MessageTypeScanCancelled
|
||||
}
|
||||
w.connManager.BroadcastToUser(job.UserID, wsync.BroadcastMessage{
|
||||
Type: wsync.MessageTypeScanComplete,
|
||||
Type: msgType,
|
||||
Data: map[string]interface{}{
|
||||
"job_id": job.ID,
|
||||
"files_scanned": filesScanned,
|
||||
@@ -972,6 +1039,7 @@ func (w *Worker) EnqueueJob(job *Job) error {
|
||||
|
||||
select {
|
||||
case w.jobQueue <- job:
|
||||
w.recordPending(job)
|
||||
return nil
|
||||
case <-w.ctx.Done():
|
||||
return fmt.Errorf("worker is shutting down")
|
||||
@@ -1005,19 +1073,33 @@ func (w *Worker) GetActiveScanJobs() []JobResult {
|
||||
return active
|
||||
}
|
||||
|
||||
// CancelJob cancels a pending or running job by id. Pending jobs are
|
||||
// marked cancelled so processJob skips them at dequeue (no context
|
||||
// exists yet); running jobs have their per-job context cancelled — the
|
||||
// job stops at its next context check and processJob finalises the
|
||||
// status as cancelled.
|
||||
func (w *Worker) CancelJob(jobID string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if result, exists := w.results[jobID]; exists {
|
||||
if result.Status == JobStatusRunning || result.Status == JobStatusPending {
|
||||
result.Status = JobStatusCancelled
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("job cannot be cancelled")
|
||||
result, exists := w.results[jobID]
|
||||
if !exists {
|
||||
return ErrJobNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("job not found")
|
||||
switch result.Status {
|
||||
case JobStatusPending:
|
||||
result.Status = JobStatusCancelled
|
||||
return nil
|
||||
case JobStatusRunning:
|
||||
result.Status = JobStatusCancelled
|
||||
if cancel, ok := w.cancels[jobID]; ok {
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return ErrJobNotCancellable
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) Shutdown() {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A job cancelled while still queued must never execute. processJob
|
||||
// sees the pending-cancel marker at dequeue and leaves the result
|
||||
// untouched; a scan without params would otherwise fail with
|
||||
// "library_id required" and flip the status to failed — so an empty
|
||||
// error field proves the job body never ran.
|
||||
func TestCancelQueuedJobNeverExecutes(t *testing.T) {
|
||||
// Zero workers: the job stays queued until processJob is driven by
|
||||
// hand below.
|
||||
w := NewWorkerWithConfig(0, 10, nil)
|
||||
|
||||
job := &Job{
|
||||
ID: "queued-cancel",
|
||||
Type: JobTypeScan,
|
||||
Context: context.Background(),
|
||||
Params: map[string]interface{}{},
|
||||
}
|
||||
if err := w.EnqueueJob(job); err != nil {
|
||||
t.Fatalf("enqueue failed: %v", err)
|
||||
}
|
||||
|
||||
// The pending result must exist before any worker picks the job up,
|
||||
// so status polling can see (and cancel) queued jobs.
|
||||
result, ok := w.GetJobStatus("queued-cancel")
|
||||
if !ok {
|
||||
t.Fatal("queued job has no result entry")
|
||||
}
|
||||
if result.Status != JobStatusPending {
|
||||
t.Fatalf("expected pending status, got %q", result.Status)
|
||||
}
|
||||
|
||||
if err := w.CancelJob("queued-cancel"); err != nil {
|
||||
t.Fatalf("CancelJob failed: %v", err)
|
||||
}
|
||||
|
||||
// Simulate the dequeue that races the cancellation.
|
||||
w.processJob(job)
|
||||
|
||||
result, _ = w.GetJobStatus("queued-cancel")
|
||||
if result.Status != JobStatusCancelled {
|
||||
t.Fatalf("expected cancelled status after dequeue, got %q", result.Status)
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Errorf("skipped job must carry no processing error, got %q", result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelling a running job must invoke its per-job context cancel func
|
||||
// — the old CancelJob only flipped a map status and the job kept
|
||||
// running to completion.
|
||||
func TestCancelRunningJobCancelsContext(t *testing.T) {
|
||||
w := NewWorker(1, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
job := &Job{ID: "running-cancel", Type: JobTypeScan, Context: ctx}
|
||||
|
||||
// Seed the state processJob establishes while a job runs.
|
||||
w.mu.Lock()
|
||||
w.results[job.ID] = &JobResult{JobID: job.ID, Status: JobStatusRunning}
|
||||
w.cancels[job.ID] = cancel
|
||||
w.mu.Unlock()
|
||||
|
||||
if err := w.CancelJob(job.ID); err != nil {
|
||||
t.Fatalf("CancelJob failed: %v", err)
|
||||
}
|
||||
|
||||
if ctx.Err() == nil {
|
||||
t.Fatal("CancelJob must cancel the job's context")
|
||||
}
|
||||
result, _ := w.GetJobStatus(job.ID)
|
||||
if result.Status != JobStatusCancelled {
|
||||
t.Fatalf("expected cancelled status, got %q", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelJobErrors(t *testing.T) {
|
||||
w := NewWorker(1, nil)
|
||||
|
||||
if err := w.CancelJob("missing-job"); !errors.Is(err, ErrJobNotFound) {
|
||||
t.Errorf("unknown job: expected ErrJobNotFound, got %v", err)
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.results["done-job"] = &JobResult{JobID: "done-job", Status: JobStatusCompleted}
|
||||
w.results["failed-job"] = &JobResult{JobID: "failed-job", Status: JobStatusFailed}
|
||||
w.results["cancelled-job"] = &JobResult{JobID: "cancelled-job", Status: JobStatusCancelled}
|
||||
w.mu.Unlock()
|
||||
|
||||
for _, id := range []string{"done-job", "failed-job", "cancelled-job"} {
|
||||
if err := w.CancelJob(id); !errors.Is(err, ErrJobNotCancellable) {
|
||||
t.Errorf("terminal job %q: expected ErrJobNotCancellable, got %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Both enqueue paths must register a pending result: auto-scans arrive
|
||||
// via Enqueue (fire-and-forget) and manual scans via EnqueueJob, and a
|
||||
// queued job must be visible to status polling and cancellable before
|
||||
// a worker dequeues it.
|
||||
func TestEnqueueRecordsPendingResult(t *testing.T) {
|
||||
w := NewWorkerWithConfig(0, 10, nil)
|
||||
|
||||
fireAndForget := &Job{
|
||||
ID: "via-enqueue",
|
||||
Type: JobTypeScan,
|
||||
Context: context.Background(),
|
||||
Params: map[string]interface{}{"library_id": "lib-auto"},
|
||||
}
|
||||
w.Enqueue(fireAndForget)
|
||||
|
||||
tracked := &Job{
|
||||
ID: "via-enqueue-job",
|
||||
Type: JobTypeScan,
|
||||
Context: context.Background(),
|
||||
Params: map[string]interface{}{"library_id": "lib-manual"},
|
||||
}
|
||||
if err := w.EnqueueJob(tracked); err != nil {
|
||||
t.Fatalf("EnqueueJob failed: %v", err)
|
||||
}
|
||||
|
||||
for id, libID := range map[string]string{
|
||||
"via-enqueue": "lib-auto",
|
||||
"via-enqueue-job": "lib-manual",
|
||||
} {
|
||||
result, ok := w.GetJobStatus(id)
|
||||
if !ok {
|
||||
t.Fatalf("%s: no pending result recorded", id)
|
||||
}
|
||||
if result.Status != JobStatusPending {
|
||||
t.Errorf("%s: expected pending, got %q", id, result.Status)
|
||||
}
|
||||
if result.LibraryID != libID {
|
||||
t.Errorf("%s: expected library_id %q, got %q", id, libID, result.LibraryID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A scan whose context is already cancelled must abort immediately
|
||||
// with context.Canceled — before counting, walking, or running the
|
||||
// archive pass. processJob classifies that error as a cancelled job.
|
||||
func TestScanFoldersCancelledContext(t *testing.T) {
|
||||
s := NewMediaScanner(nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if err := s.ScanFolders(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const (
|
||||
FormatGroupReflowable FormatGroup = "reflowable"
|
||||
FormatGroupFixedLayout FormatGroup = "fixed_layout"
|
||||
FormatGroupComicArchive FormatGroup = "comic_archive"
|
||||
FormatGroupAudiobook FormatGroup = "audiobook"
|
||||
FormatGroupUnknown FormatGroup = "unknown"
|
||||
)
|
||||
|
||||
|
||||
@@ -87,6 +87,15 @@ func ConvertProgress(sourceFormat, targetFormat FormatGroup, sourceData map[stri
|
||||
result["panel"] = panel
|
||||
}
|
||||
|
||||
case FormatGroupAudiobook:
|
||||
// Audio progress is percentage + global seconds; there is no page
|
||||
// grid to re-derive, so the percentage carries across as-is and
|
||||
// the second-based locator follows when present.
|
||||
result["percentage"] = percentage
|
||||
if offset, ok := sourceData["audio_offset_seconds"].(int); ok {
|
||||
result["audio_offset_seconds"] = offset
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported target format: %s", targetFormat)
|
||||
}
|
||||
@@ -108,7 +117,7 @@ func extractPercentage(sourceFormat FormatGroup, sourceData map[string]interface
|
||||
}
|
||||
}
|
||||
|
||||
case FormatGroupFixedLayout, FormatGroupComicArchive:
|
||||
case FormatGroupFixedLayout, FormatGroupComicArchive, FormatGroupAudiobook:
|
||||
if page, ok := sourceData["page"].(int); ok {
|
||||
if total, ok := sourceData["total_pages"].(int); ok && total > 0 {
|
||||
return float64(page) / float64(total)
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
MessageTypeScanProgress = "scan_progress"
|
||||
MessageTypeScanComplete = "scan_complete"
|
||||
MessageTypeScanError = "scan_error"
|
||||
MessageTypeScanCancelled = "scan_cancelled"
|
||||
)
|
||||
|
||||
// BroadcastMessage represents a message to broadcast to connected clients
|
||||
|
||||
@@ -18,6 +18,36 @@ const config: Config = {
|
||||
"theme-catppuccin-macchiato",
|
||||
"theme-catppuccin-frappe",
|
||||
"theme-catppuccin-latte",
|
||||
// Omarchy dark themes (theme_options.go). Theme ids are runtime values
|
||||
// applied via `theme-<name>` classes, so each must be safelisted or the
|
||||
// CSS build purges its variable block from @layer base.
|
||||
"theme-vantablack",
|
||||
"theme-last-horizon",
|
||||
"theme-ethereal",
|
||||
"theme-hackerman",
|
||||
"theme-matte-black",
|
||||
"theme-solitude",
|
||||
"theme-retro-82",
|
||||
"theme-osaka-jade",
|
||||
"theme-kanagawa",
|
||||
"theme-miasma",
|
||||
"theme-lumon",
|
||||
"theme-ristretto",
|
||||
"theme-gruvbox",
|
||||
"theme-everforest",
|
||||
// Light themes
|
||||
"theme-tokyo-night-day",
|
||||
"theme-solarized-light",
|
||||
"theme-one-light",
|
||||
"theme-ayu-light",
|
||||
"theme-github-light",
|
||||
"theme-gruvbox-light",
|
||||
"theme-everforest-light",
|
||||
"theme-kanagawa-lotus",
|
||||
"theme-rose-pine",
|
||||
"theme-lupine",
|
||||
"theme-flexoki-light",
|
||||
"theme-white",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
@@ -10,7 +10,7 @@ templ Admin(user User, stats AdminStats) {
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body x-data="admin" x-init="loadWatchStatus(); checkActiveScans()" class="theme-{ user.Theme }">
|
||||
<body x-data="admin" x-init="loadWatchStatus(); checkActiveScans()" class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/admin")
|
||||
<main class="p-8">
|
||||
<div class="max-w-4xl">
|
||||
@@ -97,9 +97,14 @@ templ Admin(user User, stats AdminStats) {
|
||||
@Icon("refresh", "h-5 w-5")
|
||||
Scanning Libraries
|
||||
</h3>
|
||||
<button @click="hideScanProgress()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="scan-cancel-all-btn" @click="cancelAllScans()" class="hidden icon-btn" aria-label="Cancel all scans" title="Cancel all scans">
|
||||
@Icon("x-circle", "h-5 w-5")
|
||||
</button>
|
||||
<button @click="hideScanProgress()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
|
||||
@@ -11,7 +11,7 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
<body class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/admin/archived")
|
||||
<main class="p-8">
|
||||
<div class="max-w-5xl">
|
||||
|
||||
+136
-114
@@ -29,7 +29,29 @@ 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><body class=\"theme-{ user.Theme }\">")
|
||||
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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 = []any{"theme-" + user.Theme}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -37,7 +59,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"p-8\"><div class=\"max-w-5xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between gap-4 flex-wrap\"><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, "<main class=\"p-8\"><div class=\"max-w-5xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between gap-4 flex-wrap\"><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
|
||||
}
|
||||
@@ -45,40 +67,40 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Archived Items</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Books hidden because their files are missing from disk. Reading history is kept in case a file returns; moves are detected automatically. ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Archived Items</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Books hidden because their files are missing from disk. Reading history is kept in case a file returns; moves are detected automatically. ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if retentionDays > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Archived items are permanently deleted after ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Archived items are permanently deleted after ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(retentionDays)
|
||||
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: `admin_archived.templ`, Line: 31, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
_, 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, 5, " days (ARCHIVE_RETENTION_DAYS).")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " days (ARCHIVE_RETENTION_DAYS).")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Archived items are kept until manually purged (retention disabled).")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Archived items are kept until manually purged (retention disabled).")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(items) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<button type=\"button\" onclick=\"purgeArchivedItems()\" class=\"btn btn-secondary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<button type=\"button\" onclick=\"purgeArchivedItems()\" class=\"btn btn-secondary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -86,146 +108,146 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Purge All Archived</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Purge All Archived</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card p-8 text-center\"><p style=\"color: var(--text-secondary)\">Nothing is archived or missing. All clear.</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card p-8 text-center\"><p style=\"color: var(--text-secondary)\">Nothing is archived or missing. All clear.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"card overflow-hidden\"><table class=\"w-full text-sm\"><thead><tr class=\"border-b text-left\" style=\"border-color: var(--border); color: var(--text-secondary);\"><th class=\"px-4 py-3 font-semibold\">Book</th><th class=\"px-4 py-3 font-semibold\">Library</th><th class=\"px-4 py-3 font-semibold\">Status</th><th class=\"px-4 py-3 font-semibold text-right\">Actions</th></tr></thead> <tbody>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"card overflow-hidden\"><table class=\"w-full text-sm\"><thead><tr class=\"border-b text-left\" style=\"border-color: var(--border); color: var(--text-secondary);\"><th class=\"px-4 py-3 font-semibold\">Book</th><th class=\"px-4 py-3 font-semibold\">Library</th><th class=\"px-4 py-3 font-semibold\">Status</th><th class=\"px-4 py-3 font-semibold text-right\">Actions</th></tr></thead> <tbody>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, item := range items {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<tr class=\"border-b\" style=\"border-color: color-mix(in srgb, var(--text-primary) 5%, transparent);\"><td class=\"px-4 py-3\"><p class=\"font-medium\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, 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}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.Author != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, 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}
|
||||
}
|
||||
_, 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, 16, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<p class=\"font-mono text-xs break-all\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<tr class=\"border-b\" style=\"border-color: color-mix(in srgb, var(--text-primary) 5%, transparent);\"><td class=\"px-4 py-3\"><p class=\"font-medium\" 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(item.FilePath)
|
||||
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: 70, Col: 103}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 66, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></td><td class=\"px-4 py-3\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, 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}
|
||||
if item.Author != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<p style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `admin_archived.templ`, Line: 68, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td class=\"px-4 py-3\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"font-mono text-xs break-all\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
|
||||
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: 73, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 70, Col: 103}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td class=\"px-4 py-3 text-right whitespace-nowrap\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</p></td><td class=\"px-4 py-3\" 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(item.LibraryName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 72, Col: 86}
|
||||
}
|
||||
_, 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, 21, "</td><td class=\"px-4 py-3\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `admin_archived.templ`, Line: 73, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</td><td class=\"px-4 py-3 text-right whitespace-nowrap\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.MatchID != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<button type=\"button\" data-merge-source=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, 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}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-merge-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, 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}
|
||||
}
|
||||
_, 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, "\" class=\"btn btn-ghost text-xs\" title=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<button type=\"button\" data-merge-source=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("Merge this archived copy's reading data into the active copy \"" + item.MatchTitle + "\" (" + item.MatchLibraryName + ") and remove the archived entry")
|
||||
templ_7745c5c3_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: 81, Col: 174}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 78, 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, 24, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" data-merge-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `admin_archived.templ`, Line: 79, Col: 46}
|
||||
}
|
||||
_, 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, 25, "\" class=\"btn btn-ghost text-xs\" title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `admin_archived.templ`, Line: 81, Col: 174}
|
||||
}
|
||||
_, 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, 26, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -233,25 +255,25 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Merge</button> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "Merge</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<button type=\"button\" data-unarchive=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<button type=\"button\" data-unarchive=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
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: `admin_archived.templ`, Line: 89, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"btn btn-ghost text-xs\" title=\"Restore to libraries now; if the file is still gone the next scan hides it again\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"btn btn-ghost text-xs\" title=\"Restore to libraries now; if the file is still gone the next scan hides it again\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -259,20 +281,20 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "Restore</button> <button type=\"button\" data-delete-archived=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Restore</button> <button type=\"button\" data-delete-archived=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
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: `admin_archived.templ`, Line: 98, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
_, 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, 29, "\" class=\"btn btn-ghost text-xs\" title=\"Delete this item and its reading history permanently\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"btn btn-ghost text-xs\" title=\"Delete this item and its reading history permanently\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -280,17 +302,17 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Delete</button></td></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Delete</button></td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</tbody></table></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</tbody></table></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></main></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("conflict-" + conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 27, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 27, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -80,7 +80,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d copies", len(conflict.Items)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 33, Col: 87}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 33, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -93,7 +93,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.LibraryName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 36, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 36, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -106,7 +106,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.SHAShort)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 38, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 38, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -119,7 +119,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflict.ID + "/resolve")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 46, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 46, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -132,7 +132,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 48, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 48, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -194,7 +194,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 71, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 71, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -212,7 +212,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 73, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 73, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -230,7 +230,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 75, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 75, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -243,7 +243,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f MB", float64(item.FileSize)/(1024*1024)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 77, Col: 64}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 77, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -261,7 +261,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 79, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 79, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -279,7 +279,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 81, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 81, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -297,7 +297,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflictID + "/resolve")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 87, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 87, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -310,7 +310,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"action": "keep", "keep_uuid": "%s"}`, item.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 88, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 88, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -323,7 +323,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflictID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 89, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 89, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -383,7 +383,7 @@ func AdminHashConflicts(user User, conflicts []HashConflictData) templ.Component
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package templates
|
||||
|
||||
templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64) {
|
||||
import "bookhoard/internal/database"
|
||||
|
||||
templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64, libraryTypes []database.LibraryTypes) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -11,7 +13,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
<body x-data="admin" class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/admin/library")
|
||||
<main class="p-8">
|
||||
<div class="max-w-4xl">
|
||||
@@ -88,9 +90,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
||||
<div class="mb-6">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Type</label>
|
||||
<select name="type" class="input" required>
|
||||
<option value="ebooks">Ebooks</option>
|
||||
<option value="comics">Comics</option>
|
||||
<option value="manga">Manga</option>
|
||||
@LibraryTypeOptions(libraryTypes)
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
@@ -173,57 +173,20 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function toggleLibraryPanel(btn) {
|
||||
var libId = btn.dataset.libId;
|
||||
var panel = document.getElementById('library-panel-' + libId);
|
||||
if (panel.innerHTML.trim() !== '') {
|
||||
panel.innerHTML = '';
|
||||
} else {
|
||||
htmx.ajax('GET', '/admin/library/' + libId + '/panel', {
|
||||
target: '#library-panel-' + libId,
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
}
|
||||
function openEditModal(id, name, description) {
|
||||
document.getElementById('edit-library-form').setAttribute('hx-put', '/admin/library/' + id);
|
||||
document.getElementById('edit-library-name').value = name;
|
||||
document.getElementById('edit-library-desc').value = description;
|
||||
htmx.process(document.getElementById('edit-library-form'));
|
||||
document.getElementById('edit-library-modal').classList.remove('hidden');
|
||||
}
|
||||
function openDeleteModal(id, name) {
|
||||
const btn = document.getElementById('delete-library-confirm');
|
||||
btn.setAttribute('hx-delete', '/admin/library/' + id);
|
||||
btn.setAttribute('hx-target', '#libraries-container');
|
||||
btn.setAttribute('hx-swap', 'innerHTML');
|
||||
document.getElementById('delete-library-name').textContent = name;
|
||||
htmx.process(btn);
|
||||
document.getElementById('delete-library-modal').classList.remove('hidden');
|
||||
}
|
||||
function openFolderBrowser(targetInputId, libraryId) {
|
||||
const content = document.getElementById('folder-browser-content');
|
||||
content.setAttribute('hx-get', '/admin/library/browse');
|
||||
content.setAttribute('hx-vals', '{"target_input": "' + targetInputId + '", "library_id": "' + libraryId + '"}');
|
||||
content.setAttribute('hx-trigger', 'load');
|
||||
htmx.process(content);
|
||||
document.getElementById('folder-browser-modal').classList.remove('hidden');
|
||||
}
|
||||
// Close the delete-library modal after a successful delete: the
|
||||
// htmx swap already removes the deleted row from the libraries
|
||||
// list, but the modal lives outside the swapped container and
|
||||
// would otherwise stay open.
|
||||
document.getElementById('delete-library-confirm').addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.successful) {
|
||||
document.getElementById('delete-library-modal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
// LibraryTypeOptions renders the <option> list for a library-type <select>
|
||||
// from the DB-backed library_types rows, so new types show up without
|
||||
// template edits.
|
||||
templ LibraryTypeOptions(types []database.LibraryTypes) {
|
||||
for _, t := range types {
|
||||
<option value={ t.Name }>{ LibraryTypeDisplayName(t.Name) }</option>
|
||||
}
|
||||
}
|
||||
|
||||
templ LibraryList(user User, libraries []LibraryData, users []User) {
|
||||
if len(libraries) == 0 {
|
||||
<div class="card text-center py-16">
|
||||
@@ -273,7 +236,7 @@ templ LibraryList(user User, libraries []LibraryData, users []User) {
|
||||
</button>
|
||||
<button
|
||||
data-lib-id={ library.ID }
|
||||
onclick="toggleLibraryPanel(this)"
|
||||
@click="toggleLibraryPanel($el)"
|
||||
class="btn btn-secondary text-xs px-3 py-1.5"
|
||||
>
|
||||
@Icon("chevron-down", "h-4 w-4")
|
||||
@@ -331,7 +294,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
|
||||
type="button"
|
||||
data-library-id={ libraryID }
|
||||
data-target-input={ "folder-input-" + libraryID }
|
||||
onclick="openFolderBrowser(this.dataset.targetInput, this.dataset.libraryId)"
|
||||
@click="openFolderBrowser($el.dataset.targetInput, $el.dataset.libraryId)"
|
||||
class="btn btn-secondary text-sm shrink-0"
|
||||
>
|
||||
@Icon("folder", "h-4 w-4")
|
||||
@@ -392,7 +355,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
|
||||
data-edit-id={ libraryID }
|
||||
data-edit-name={ library.Name }
|
||||
data-edit-desc={ library.Description }
|
||||
onclick="openEditModal(this.dataset.editId, this.dataset.editName, this.dataset.editDesc)"
|
||||
@click="openEditModal($el.dataset.editId, $el.dataset.editName, $el.dataset.editDesc)"
|
||||
class="btn btn-secondary text-sm"
|
||||
>
|
||||
@Icon("edit", "h-4 w-4")
|
||||
@@ -401,7 +364,7 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F
|
||||
<button
|
||||
data-delete-id={ libraryID }
|
||||
data-delete-name={ library.Name }
|
||||
onclick="openDeleteModal(this.dataset.deleteId, this.dataset.deleteName)"
|
||||
@click="openDeleteModal($el.dataset.deleteId, $el.dataset.deleteName)"
|
||||
class="btn btn-danger text-sm"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
|
||||
+338
-246
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,7 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
<body class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/admin/library")
|
||||
<main class="p-8">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
|
||||
@@ -29,7 +29,29 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
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>Processing Issues - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||
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>Processing Issues - 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
|
||||
}
|
||||
var templ_7745c5c3_Var2 = []any{"theme-" + user.Theme}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -37,7 +59,7 @@ 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, 2, "<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, "<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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -45,17 +67,17 @@ 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, 3, "</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, 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>")
|
||||
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, 4, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if stats.ErrorCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<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, 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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -63,26 +85,26 @@ 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, 6, "<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, 8, "<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_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, 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: `admin_processing_issues.templ`, Line: 37, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p></div>")
|
||||
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, 8, "<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);\">")
|
||||
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
|
||||
}
|
||||
@@ -90,26 +112,26 @@ 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, 9, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Warnings</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\">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_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 46, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if stats.InfoCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-info);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-info);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-info);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-info);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -117,31 +139,31 @@ 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, 12, "<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\">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_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, 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: 55, Col: 91}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 55, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<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, 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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -149,140 +171,122 @@ 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, 16, "</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, 18, "</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, 17, "<!-- Issues List --> <div class=\"space-y-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<!-- 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, 18, "<div class=\"card p-6\" id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, 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_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"><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_Var6 string
|
||||
templ_7745c5c3_Var6, 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_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
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.JoinStringErrs(issue.IssueDescription)
|
||||
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: 75, Col: 96}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 71, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
_, 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, "</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, 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.IssueType)
|
||||
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: 77, Col: 140}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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, "</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, 22, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
||||
templ_7745c5c3_Var9, 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: 78, Col: 144}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 75, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</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, 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> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||
templ_7745c5c3_Var10, 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: 79, Col: 139}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 77, Col: 140}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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)\">Library:</span> ")
|
||||
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> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||
templ_7745c5c3_Var11, 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: 80, Col: 149}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 78, Col: 144}
|
||||
}
|
||||
_, 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></div></div><div class=\"ml-2 shrink-0\">")
|
||||
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> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 79, Col: 139}
|
||||
}
|
||||
_, 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> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 80, Col: 149}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></div></div><div class=\"ml-2 shrink-0\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if issue.Severity == "error" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"badge status-failed\">")
|
||||
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_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 85, Col: 62}
|
||||
}
|
||||
_, 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, 27, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if issue.Severity == "warning" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<span class=\"badge status-pending\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, 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_Var13))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -290,17 +294,17 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"badge status-processing\">")
|
||||
} 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_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
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: 89, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 87, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -308,39 +312,57 @@ 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, 32, "</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, 33, "<button hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, 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: 96, Col: 113}
|
||||
}
|
||||
_, 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, 34, "\" hx-target=\"")
|
||||
} 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.ResolveAttributeValue("#issue-" + issue.ID)
|
||||
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: 97, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 89, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
_, 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, 35, "\" hx-swap=\"outerHTML\" hx-confirm=\"Dismiss this issue?\" class=\"btn btn-secondary text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></div><div class=\"flex gap-3 mt-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<button hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 96, Col: 113}
|
||||
}
|
||||
_, 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, 36, "\" hx-target=\"")
|
||||
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)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 97, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -348,22 +370,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, 36, "Dismiss</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Dismiss</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></main></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
<body class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/admin/settings")
|
||||
<main class="p-8">
|
||||
<div class="max-w-4xl">
|
||||
|
||||
+265
-243
File diff suppressed because it is too large.
Load diff
+77
-47
@@ -29,7 +29,29 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
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>Admin Dashboard - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus(); checkActiveScans()\" class=\"theme-{ user.Theme }\">")
|
||||
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>Admin Dashboard - 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
|
||||
}
|
||||
var templ_7745c5c3_Var2 = []any{"theme-" + user.Theme}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body x-data=\"admin\" x-init=\"loadWatchStatus(); checkActiveScans()\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -37,7 +59,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<main class=\"p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -45,7 +67,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Dashboard</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Overview of your Bookhoard instance</p></div><!-- Stats Grid --><div class=\"grid grid-cols-2 md:grid-cols-4 gap-4 mb-6\"><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Dashboard</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Overview of your Bookhoard instance</p></div><!-- Stats Grid --><div class=\"grid grid-cols-2 md:grid-cols-4 gap-4 mb-6\"><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -53,37 +75,16 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Libraries</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Libraries</span></div><p class=\"text-2xl font-bold\" 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(stats.LibraryCount)
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 33, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 33, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p></div><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("book", "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, 6, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Books</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 40, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -91,20 +92,20 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("users", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Icon("book", "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, 8, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Users</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Books</span></div><p class=\"text-2xl 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.UserCount)
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 47, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 40, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -112,24 +113,45 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("users", "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, 10, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Users</span></div><p class=\"text-2xl 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.UserCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 47, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("device", "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, 10, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Devices</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Devices</span></div><p class=\"text-2xl 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.DeviceCount)
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 54, Col: 91}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 54, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div></div><!-- Watch Status --><div class=\"stat-card mb-6\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p></div></div><!-- Watch Status --><div class=\"stat-card mb-6\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -137,7 +159,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span><div class=\"flex-1\"><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">File Watcher</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Auto-detects new files in library folders</p></div><div class=\"text-right text-sm\" style=\"color: var(--text-secondary)\"><span class=\"inline-block w-2 h-2 rounded-full mr-2\" style=\"background-color: var(--status-success);\"></span> Watching <span id=\"watch-count\">0</span> libraries</div></div></div><!-- Quick Actions --><div class=\"card p-6\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary)\">Quick Actions</h3><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-4\"><button @click=\"scanAllLibraries()\" class=\"btn btn-primary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span><div class=\"flex-1\"><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">File Watcher</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Auto-detects new files in library folders</p></div><div class=\"text-right text-sm\" style=\"color: var(--text-secondary)\"><span class=\"inline-block w-2 h-2 rounded-full mr-2\" style=\"background-color: var(--status-success);\"></span> Watching <span id=\"watch-count\">0</span> libraries</div></div></div><!-- Quick Actions --><div class=\"card p-6\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary)\">Quick Actions</h3><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-4\"><button @click=\"scanAllLibraries()\" class=\"btn btn-primary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -145,7 +167,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Scan All Libraries</span> <span class=\"text-xs font-normal opacity-80\">Re-scan existing files and detect new items</span></button> <a href=\"/admin/library\" class=\"btn btn-secondary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Scan All Libraries</span> <span class=\"text-xs font-normal opacity-80\">Re-scan existing files and detect new items</span></button> <a href=\"/admin/library\" class=\"btn btn-secondary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -153,7 +175,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Manage Libraries</span> <span class=\"text-xs font-normal opacity-80\">Add or remove libraries and folders</span></a></div></div><!-- Scan Progress Section --><div id=\"scan-progress-container\" class=\"card hidden mt-6 p-6 opacity-0 -translate-y-2.5 transition-all duration-300 ease-out\"><div class=\"flex justify-between items-center mb-4\"><h3 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Manage Libraries</span> <span class=\"text-xs font-normal opacity-80\">Add or remove libraries and folders</span></a></div></div><!-- Scan Progress Section --><div id=\"scan-progress-container\" class=\"card hidden mt-6 p-6 opacity-0 -translate-y-2.5 transition-all duration-300 ease-out\"><div class=\"flex justify-between items-center mb-4\"><h3 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -161,7 +183,15 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Scanning Libraries</h3><button @click=\"hideScanProgress()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Scanning Libraries</h3><div class=\"flex items-center gap-2\"><button id=\"scan-cancel-all-btn\" @click=\"cancelAllScans()\" class=\"hidden icon-btn\" aria-label=\"Cancel all scans\" title=\"Cancel all scans\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("x-circle", "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, 18, "</button> <button @click=\"hideScanProgress()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -169,7 +199,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full rounded-full h-3\" style=\"background-color: var(--surface-hover);\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><div id=\"library-progress-list\" class=\"space-y-3\"></div><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-xl border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2 flex items-center gap-2\" style=\"color: var(--status-success);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</button></div></div><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full rounded-full h-3\" style=\"background-color: var(--surface-hover);\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><div id=\"library-progress-list\" class=\"space-y-3\"></div><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-xl border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2 flex items-center gap-2\" style=\"color: var(--status-success);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -177,7 +207,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"></div><div class=\"mt-4 flex gap-2\"><button @click=\"window.location.reload()\" class=\"btn btn-primary\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn btn-secondary\">Dismiss</button></div></div></div></div></main></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"></div><div class=\"mt-4 flex gap-2\"><button @click=\"window.location.reload()\" class=\"btn btn-primary\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn btn-secondary\">Dismiss</button></div></div></div></div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body class="theme-{ currentUser.Theme }">
|
||||
<body class={ "theme-" + currentUser.Theme }>
|
||||
@Header(currentUser, "/admin/users")
|
||||
<!-- Modal Container (populated by HTMX) -->
|
||||
<div id="modal-container"></div>
|
||||
|
||||
+123
-101
@@ -70,7 +70,29 @@ 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><body class=\"theme-{ currentUser.Theme }\">")
|
||||
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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 = []any{"theme-" + currentUser.Theme}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<body class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, 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, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -78,7 +100,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!-- Modal Container (populated by HTMX) --><div id=\"modal-container\"></div><main class=\"p-4 md:p-8\" x-data=\"{ createOpen: false }\"><div class=\"max-w-5xl\"><div class=\"flex items-start justify-between gap-3 mb-6\"><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, 6, "<!-- Modal Container (populated by HTMX) --><div id=\"modal-container\"></div><main class=\"p-4 md:p-8\" x-data=\"{ createOpen: false }\"><div class=\"max-w-5xl\"><div class=\"flex items-start justify-between gap-3 mb-6\"><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
|
||||
}
|
||||
@@ -86,7 +108,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
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)\">User Management</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage user accounts and permissions</p></div><button type=\"button\" class=\"btn btn-primary text-sm px-3 py-2 shrink-0\" x-on:click=\"createOpen = true\">+ Create User</button></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">User Management</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage user accounts and permissions</p></div><button type=\"button\" class=\"btn btn-primary text-sm px-3 py-2 shrink-0\" x-on:click=\"createOpen = true\">+ Create User</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -94,25 +116,25 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Users: card list. Stacked rows on purpose — identity, created,\n\t\t\t\t role, and actions each get their own full-width row so nothing\n\t\t\t\t truncates into confusion on a phone. --><div class=\"space-y-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<!-- Users: card list. Stacked rows on purpose — identity, created,\n\t\t\t\t role, and actions each get their own full-width row so nothing\n\t\t\t\t truncates into confusion on a phone. --><div class=\"space-y-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, user := range users {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
||||
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: `admin_users.templ`, Line: 102, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" class=\"card overflow-hidden\"><div class=\"p-4 sm:p-5\"><!-- Identity --><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"card overflow-hidden\"><div class=\"p-4 sm:p-5\"><!-- Identity --><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -120,143 +142,143 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span><div class=\"min-w-0\"><div class=\"flex items-center gap-2\"><h3 class=\"font-semibold truncate\" 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(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 111, Col: 96}
|
||||
}
|
||||
_, 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, 10, "</h3>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.ID == currentUser.ID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"badge shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">You</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><p class=\"text-sm truncate\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, 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}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p></div></div><!-- Created: its own line — a full date never wraps --><p class=\"text-xs mt-2\" style=\"color: var(--text-secondary)\">Created ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span><div class=\"min-w-0\"><div class=\"flex items-center gap-2\"><h3 class=\"font-semibold truncate\" 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(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
||||
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: 121, Col: 73}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 111, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p><!-- Role --><div class=\"mt-3 pt-3 border-t flex items-center justify-between gap-3\" style=\"border-color: var(--border)\"><span class=\"text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Role</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</h3>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.ID == currentUser.ID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"badge shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">You</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><p class=\"text-sm truncate\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 116, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p></div></div><!-- Created: its own line — a full date never wraps --><p class=\"text-xs mt-2\" style=\"color: var(--text-secondary)\">Created ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `admin_users.templ`, Line: 121, Col: 73}
|
||||
}
|
||||
_, 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, 16, "</p><!-- Role --><div class=\"mt-3 pt-3 border-t flex items-center justify-between gap-3\" style=\"border-color: var(--border)\"><span class=\"text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Role</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<!-- Last admin - disabled --> <select disabled class=\"input w-auto py-1 pr-7 text-xs opacity-50 cursor-not-allowed\" title=\"Cannot demote the last admin\"><option value=\"user\">User</option> <option value=\"admin\" selected>Admin</option></select>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<!-- Last admin - disabled --> <select disabled class=\"input w-auto py-1 pr-7 text-xs opacity-50 cursor-not-allowed\" title=\"Cannot demote the last admin\"><option value=\"user\">User</option> <option value=\"admin\" selected>Admin</option></select>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<select hx-put=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<select hx-put=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
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: `admin_users.templ`, Line: 138, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
_, 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, 17, "\" hx-target=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
||||
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: `admin_users.templ`, Line: 139, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
_, 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, 18, "\" hx-swap=\"innerHTML\" hx-trigger=\"change\" name=\"role\" class=\"input w-auto py-1 pr-7 text-xs\"><option value=\"user\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" hx-swap=\"innerHTML\" hx-trigger=\"change\" name=\"role\" class=\"input w-auto py-1 pr-7 text-xs\"><option value=\"user\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "user" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ">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, 21, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ">Admin</option></select>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ">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, 23, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ">Admin</option></select>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div><div id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><div id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
||||
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: `admin_users.templ`, Line: 150, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
_, 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, 24, "\" class=\"text-xs mt-1\"></div><!-- Actions --><div class=\"mt-3 flex gap-2\"><button hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" class=\"text-xs mt-1\"></div><!-- Actions --><div class=\"mt-3 flex gap-2\"><button hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
||||
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: `admin_users.templ`, Line: 154, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
_, 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, 25, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary text-sm px-3 py-1.5 flex-1 sm:flex-none sm:ml-auto\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary text-sm px-3 py-1.5 flex-1 sm:flex-none sm:ml-auto\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -264,12 +286,12 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Edit</button> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "Edit</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button disabled class=\"btn btn-danger text-sm px-3 py-1.5 flex-1 sm:flex-none\" title=\"Cannot delete the last admin\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<button disabled class=\"btn btn-danger text-sm px-3 py-1.5 flex-1 sm:flex-none\" title=\"Cannot delete the last admin\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -277,38 +299,38 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "Delete</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Delete</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<button hx-delete=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<button hx-delete=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
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: `admin_users.templ`, Line: 173, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" hx-target=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
||||
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: `admin_users.templ`, Line: 174, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
_, 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, 31, "\" hx-swap=\"outerHTML swap:0.5s\" hx-confirm=\"Are you sure you want to delete this user? This action cannot be undone.\" class=\"btn btn-danger text-sm px-3 py-1.5 flex-1 sm:flex-none\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" hx-swap=\"outerHTML swap:0.5s\" hx-confirm=\"Are you sure you want to delete this user? This action cannot be undone.\" class=\"btn btn-danger text-sm px-3 py-1.5 flex-1 sm:flex-none\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -316,17 +338,17 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Delete</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "Delete</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div></div></main></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div></div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -28,18 +28,18 @@ templ APIExplorer(explorer APIExplorerData) {
|
||||
<pre class="card p-4 overflow-x-auto"><code class="text-sm" style="color: var(--text-primary);">{ explorer.Endpoint.Response }</code></pre>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))" class="btn btn-secondary text-sm">
|
||||
<button @click={ "copyToClipboard(" + JSONArg(explorer.Endpoint.RequestBody) + ")" } class="btn btn-secondary text-sm">
|
||||
@Icon("copy", "h-4 w-4")
|
||||
Copy Request
|
||||
</button>
|
||||
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))" class="btn btn-secondary text-sm">
|
||||
<button @click={ "copyToClipboard(" + JSONArg(explorer.Endpoint.Response) + ")" } class="btn btn-secondary text-sm">
|
||||
@Icon("copy", "h-4 w-4")
|
||||
Copy Response
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="card p-6 mt-8 space-y-5" x-data="apiExplorerDoc" x-init="initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')">
|
||||
<div class="card p-6 mt-8 space-y-5" x-data="apiExplorerDoc" x-init={ "initAPIExplorerDoc(" + JSONArg(explorer.Endpoint.Path) + ", " + JSONArg(explorer.Endpoint.RequestBody) + ", " + JSONArg(explorer.Endpoint.Response) + ")" }>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
if explorer.Endpoint.Method == "GET" {
|
||||
<span class="badge status-completed">{ explorer.Endpoint.Method }</span>
|
||||
|
||||
+130
-91
@@ -42,7 +42,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 8, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 8, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -60,7 +60,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 10, Col: 65}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 10, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -78,7 +78,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 12, Col: 120}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 12, Col: 120}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -96,7 +96,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 14, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 14, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -114,7 +114,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 16, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 16, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -127,7 +127,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 24, Col: 131}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 24, Col: 131}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -140,13 +140,26 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 28, Col: 128}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 28, Col: 128}
|
||||
}
|
||||
_, 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, 13, "</code></pre></div><div class=\"flex gap-2\"><button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))\" class=\"btn btn-secondary text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</code></pre></div><div class=\"flex gap-2\"><button @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard(" + JSONArg(explorer.Endpoint.RequestBody) + ")")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 31, Col: 86}
|
||||
}
|
||||
_, 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, 14, "\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -154,7 +167,20 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Copy Request</button> <button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))\" class=\"btn btn-secondary text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Copy Request</button> <button @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard(" + JSONArg(explorer.Endpoint.Response) + ")")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 35, Col: 83}
|
||||
}
|
||||
_, 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, 16, "\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -162,155 +188,168 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Copy Response</button></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Copy Response</button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"card p-6 mt-8 space-y-5\" x-data=\"apiExplorerDoc\" x-init=\"initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')\"><div class=\"flex items-center gap-2 flex-wrap\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"card p-6 mt-8 space-y-5\" x-data=\"apiExplorerDoc\" x-init=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("initAPIExplorerDoc(" + JSONArg(explorer.Endpoint.Path) + ", " + JSONArg(explorer.Endpoint.RequestBody) + ", " + JSONArg(explorer.Endpoint.Response) + ")")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 42, Col: 226}
|
||||
}
|
||||
_, 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, 19, "\"><div class=\"flex items-center gap-2 flex-wrap\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "GET" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"badge status-completed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 45, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if explorer.Endpoint.Method == "DELETE" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"badge status-failed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 47, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if explorer.Endpoint.Method == "POST" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 120}
|
||||
}
|
||||
_, 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, 22, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"badge status-processing\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span class=\"badge status-completed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 51, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 45, Col: 68}
|
||||
}
|
||||
_, 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, 24, "</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if explorer.Endpoint.Method == "DELETE" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"badge status-failed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 47, Col: 65}
|
||||
}
|
||||
_, 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, 23, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if explorer.Endpoint.Method == "POST" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 49, Col: 120}
|
||||
}
|
||||
_, 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, 25, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"badge status-processing\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 51, Col: 69}
|
||||
}
|
||||
_, 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, 27, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<code class=\"text-sm\" style=\"color: var(--text-secondary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<code class=\"text-sm\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 53, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 53, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
_, 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, 26, "</code></div><div class=\"flex gap-2\"><button class=\"mode-btn btn btn-secondary text-sm\" id=\"mock-btn\" @click=\"showDocMode('mock')\">Mock Data</button> <button class=\"mode-btn btn btn-primary text-sm\" id=\"real-btn\" @click=\"showDocMode('real')\">Try It Out</button></div><div><label for=\"http-method\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Method</label> <select id=\"http-method\" class=\"input\"><option value=\"GET\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</code></div><div class=\"flex gap-2\"><button class=\"mode-btn btn btn-secondary text-sm\" id=\"mock-btn\" @click=\"showDocMode('mock')\">Mock Data</button> <button class=\"mode-btn btn btn-primary text-sm\" id=\"real-btn\" @click=\"showDocMode('real')\">Try It Out</button></div><div><label for=\"http-method\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Method</label> <select id=\"http-method\" class=\"input\"><option value=\"GET\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "GET" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " selected")
|
||||
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, 28, ">GET</option> <option value=\"POST\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">GET</option> <option value=\"POST\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "POST" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " selected")
|
||||
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, 30, ">POST</option> <option value=\"PUT\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">POST</option> <option value=\"PUT\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "PUT" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " selected")
|
||||
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, 32, ">PUT</option> <option value=\"DELETE\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">PUT</option> <option value=\"DELETE\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "DELETE" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " selected")
|
||||
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, 34, ">DELETE</option></select></div><div><label for=\"request-body\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Request Body</label> <textarea id=\"request-body\" placeholder=\"Edit request body…\" class=\"input min-h-[150px] font-mono\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">DELETE</option></select></div><div><label for=\"request-body\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Request Body</label> <textarea id=\"request-body\" placeholder=\"Edit request body…\" class=\"input min-h-[150px] font-mono\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 70, Col: 136}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 70, Col: 136}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
_, 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, 35, "</textarea></div><button id=\"try-it-out\" @click=\"tryDocEndpoint()\" class=\"btn btn-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</textarea></div><button id=\"try-it-out\" @click=\"tryDocEndpoint()\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -318,7 +357,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "Try It Out</button><div class=\"api-response hidden space-y-2\"><div class=\"flex justify-between text-sm\"><span id=\"response-status\" class=\"font-semibold\" style=\"color: var(--text-primary);\"></span> <span id=\"response-time\" style=\"color: var(--text-secondary);\"></span></div><pre class=\"card p-4 overflow-x-auto\"><code id=\"response-body\" class=\"text-sm\" style=\"color: var(--text-primary);\"></code></pre></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"copyDocRequest()\" class=\"btn btn-secondary text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "Try It Out</button><div class=\"api-response hidden space-y-2\"><div class=\"flex justify-between text-sm\"><span id=\"response-status\" class=\"font-semibold\" style=\"color: var(--text-primary);\"></span> <span id=\"response-time\" style=\"color: var(--text-secondary);\"></span></div><pre class=\"card p-4 overflow-x-auto\"><code id=\"response-body\" class=\"text-sm\" style=\"color: var(--text-primary);\"></code></pre></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"copyDocRequest()\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -326,7 +365,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Copy Request</button> <button @click=\"copyDocResponse()\" class=\"btn btn-secondary text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "Copy Request</button> <button @click=\"copyDocResponse()\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -334,7 +373,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Copy Response</button> <button @click=\"generateDocCURL()\" class=\"btn btn-secondary text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "Copy Response</button> <button @click=\"generateDocCURL()\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -342,7 +381,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "Generate cURL</button></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "Generate cURL</button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
<body
|
||||
x-data="audioReader"
|
||||
x-init="init()"
|
||||
class="theme-{ user.Theme } h-screen overflow-hidden"
|
||||
class={ "theme-" + user.Theme + " h-screen overflow-hidden" }
|
||||
style="background-color: var(--bg-primary);"
|
||||
data-media-item-id={ metadata.MediaItemID }
|
||||
data-title={ metadata.Title }
|
||||
|
||||
@@ -45,134 +45,156 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 16, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 16, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><script type=\"module\" src=\"/static/main.js\"></script></head><body x-data=\"audioReader\" x-init=\"init()\" class=\"theme-{ user.Theme } h-screen overflow-hidden\" style=\"background-color: var(--bg-primary);\" data-media-item-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><script type=\"module\" src=\"/static/main.js\"></script></head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 27, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " h-screen overflow-hidden"}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" data-title=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<body x-data=\"audioReader\" x-init=\"init()\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 28, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, 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, 4, "\" data-cover=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" style=\"background-color: var(--bg-primary);\" data-media-item-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 29, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 27, Col: 44}
|
||||
}
|
||||
_, 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, 5, "\" data-file-url=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" data-title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
||||
templ_7745c5c3_Var6, 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: 30, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 28, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" data-json=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" data-cover=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, 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_Var7, 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: 33, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 29, Col: 39}
|
||||
}
|
||||
_, 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, 7, "\"><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></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);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" data-file-url=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 30, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" data-json=\"")
|
||||
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"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 33, Col: 57}
|
||||
}
|
||||
_, 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, "\"><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></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, 8, "<img src=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, 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: 86, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 86, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
_, 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, 9, "\" alt=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, 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: 87, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 87, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
_, 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, 10, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<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, 13, "<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, 12, "</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, 14, "</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_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, 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: 96, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 96, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
_, 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, 13, "</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 x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" 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></Line truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</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 x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" 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></Line truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
</head>
|
||||
<body
|
||||
x-data="bookDetail"
|
||||
class="theme-{ user.Theme }"
|
||||
class={ "theme-" + user.Theme }
|
||||
data-format-group={ book.FormatGroup }
|
||||
data-library-id={ uuidToString(book.LibraryID) }
|
||||
data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) }
|
||||
|
||||
@@ -50,7 +50,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 26, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 26, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -89,7 +89,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Source)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 58, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 58, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -102,7 +102,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(data.Timestamp, user.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 61, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 61, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -120,7 +120,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", data.Data["percentage"].(float64)*100))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 67, Col: 73}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 67, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -148,7 +148,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", data.Data["page"].(float64)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 75, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 75, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -161,7 +161,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 75, Col: 128}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 75, Col: 128}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -175,7 +175,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", data.Data["page"].(float64)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 77, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 77, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -195,7 +195,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.Data["epubcfi"].(string))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 83, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 83, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -213,7 +213,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 88, Col: 86}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 88, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -236,7 +236,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, 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_modals.templ`, Line: 99, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 99, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -249,7 +249,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, 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_modals.templ`, Line: 103, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 103, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -262,7 +262,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, 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_modals.templ`, Line: 106, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 106, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -280,7 +280,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, 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_modals.templ`, Line: 110, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 110, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -293,7 +293,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, 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_modals.templ`, Line: 110, Col: 97}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 110, Col: 97}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -312,7 +312,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, 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_modals.templ`, Line: 115, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 115, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -372,7 +372,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 149, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 149, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -393,7 +393,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.HighlightsCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 164, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 164, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -406,7 +406,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.NotesCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 168, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 168, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -419,7 +419,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(len(book.DeletedAnnotations))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 172, Col: 106}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 172, Col: 106}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -442,7 +442,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(len(book.DeletedAnnotations))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 184, Col: 125}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 184, Col: 125}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -465,7 +465,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(tombstoneDays)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 190, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 190, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -493,7 +493,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(ann.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 197, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 197, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -506,7 +506,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(ann.AnnotationType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 200, Col: 119}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 200, Col: 119}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -519,7 +519,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(ann.DeletedAt, user.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 202, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 202, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -532,7 +532,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("restoreDeletedAnnotation('" + ann.AnnotationType + "', '" + ann.ID + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 207, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 207, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -553,7 +553,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("purgeDeletedAnnotation('" + ann.AnnotationType + "', '" + ann.ID + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 214, Col: 91}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 214, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -579,7 +579,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(ann.DisplayText)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 224, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 224, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -603,7 +603,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(ann.SecondaryText)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 229, Col: 103}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 229, Col: 103}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -681,7 +681,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath.String)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 282, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 282, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -694,7 +694,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 283, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 283, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -712,7 +712,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 289, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 289, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -746,7 +746,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 324, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 324, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -759,7 +759,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Author))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 328, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 328, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -772,7 +772,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Description))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 333, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 333, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -785,7 +785,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Summary))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 338, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 338, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -811,7 +811,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(tag)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 351, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 351, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -829,7 +829,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f", book.CommunityRating.Float64))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 385, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 385, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -858,7 +858,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Publisher))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 403, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 403, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -871,7 +871,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatDateForInput(book.DatePublished))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 408, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 408, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -884,7 +884,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Edition))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 413, Col: 76}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 413, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -897,7 +897,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Language))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 417, Col: 78}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 417, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -910,7 +910,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Genre))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 421, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 421, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -923,7 +923,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.CopyrightYear.Int32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 426, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 426, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -952,7 +952,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var47 string
|
||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Series))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 444, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 444, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -965,7 +965,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var48 string
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesNumber.Int32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 450, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 450, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -978,7 +978,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var49 string
|
||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesCount.Int32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 456, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 456, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -991,7 +991,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var50 string
|
||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.Volume.Int32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 462, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 462, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1020,7 +1020,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var51 string
|
||||
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Isbn))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 481, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 481, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1033,7 +1033,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var52 string
|
||||
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Asin))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 485, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 485, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1046,7 +1046,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var53 string
|
||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoodreadsID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 489, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 489, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1059,7 +1059,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var54 string
|
||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.OpenlibraryID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 493, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 493, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1072,7 +1072,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var55 string
|
||||
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoogleBooksID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 497, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 497, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1085,7 +1085,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var56 string
|
||||
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.WebUrl))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 501, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 501, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1119,7 +1119,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var57 string
|
||||
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.Join(book.Narrators, ", "))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 519, Col: 87}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 519, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1132,7 +1132,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var58 string
|
||||
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Abridged)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 523, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 523, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1150,7 +1150,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var59 string
|
||||
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioDurationLabel())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 530, Col: 129}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 530, Col: 129}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1189,7 +1189,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var60 string
|
||||
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "unknown")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 550, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 550, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1202,7 +1202,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var61 string
|
||||
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "no")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 551, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 551, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1215,7 +1215,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var62 string
|
||||
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 552, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 552, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1228,7 +1228,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var63 string
|
||||
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes_and_right_to_left")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 553, Col: 113}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 553, Col: 113}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1241,7 +1241,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var64 string
|
||||
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "auto")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 559, Col: 86}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 559, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1254,7 +1254,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var65 string
|
||||
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "ltr")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 560, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 560, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1267,7 +1267,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var66 string
|
||||
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "rtl")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 561, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 561, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1280,7 +1280,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var67 string
|
||||
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "vertical")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 562, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 562, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1293,7 +1293,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var68 string
|
||||
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 567, Col: 81}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 567, Col: 81}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1306,7 +1306,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var69 string
|
||||
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 571, Col: 79}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 571, Col: 79}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1319,7 +1319,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var70 string
|
||||
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 575, Col: 76}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 575, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1332,7 +1332,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var71 string
|
||||
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 579, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 579, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1345,7 +1345,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var72 string
|
||||
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 584, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 584, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1384,7 +1384,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var73 string
|
||||
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 608, Col: 56}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 608, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1397,7 +1397,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var74 string
|
||||
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 613, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 613, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1410,7 +1410,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var75 string
|
||||
templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 618, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 618, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1423,7 +1423,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var76 string
|
||||
templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 623, Col: 97}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 623, Col: 97}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var76)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1436,7 +1436,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var77 string
|
||||
templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FileLocation)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 629, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 629, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
+708
-686
File diff suppressed because it is too large.
Load diff
@@ -28,7 +28,7 @@ templ BookShelf(
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body
|
||||
class="theme-{ user.Theme }"
|
||||
class={ "theme-" + user.Theme }
|
||||
x-data="bookshelf"
|
||||
x-init="initBookshelf()"
|
||||
>
|
||||
@@ -139,6 +139,12 @@ templ BookShelf(
|
||||
<div class="p-6 space-y-5">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library</label>
|
||||
<!-- Do NOT wire a `change` handler (or hx-trigger="change")
|
||||
on this select: header.ts restoreLibrarySelection may
|
||||
fire a synthetic change on page load, and a handler
|
||||
here would re-fetch the grid at offset 0, stomping
|
||||
SSR pagination (?offset=). User changes go through
|
||||
bookshelf.ts's own wiring only. -->
|
||||
<select id="library-select" name="library_id" class="input">
|
||||
if len(libraries) == 0 {
|
||||
<option value="">No libraries available</option>
|
||||
|
||||
+195
-173
@@ -45,7 +45,29 @@ 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><body class=\"theme-{ user.Theme }\" x-data=\"bookshelf\" x-init=\"initBookshelf()\">")
|
||||
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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 = []any{"theme-" + user.Theme}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" x-data=\"bookshelf\" x-init=\"initBookshelf()\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -53,7 +75,7 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-6\"><form id=\"filter-form\" hx-get=\"/api/media-items/search\" hx-target=\"#books-grid\"><!-- Toolbar --><div class=\"flex flex-wrap items-center gap-3 mb-6\"><div class=\"relative flex-1 min-w-[200px]\"><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, 4, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-6\"><form id=\"filter-form\" hx-get=\"/api/media-items/search\" hx-target=\"#books-grid\"><!-- Toolbar --><div class=\"flex flex-wrap items-center gap-3 mb-6\"><div class=\"relative flex-1 min-w-[200px]\"><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
|
||||
}
|
||||
@@ -61,7 +83,7 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span> <input name=\"q\" type=\"text\" class=\"input pl-10\" placeholder=\"Search all fields…\"></div><select name=\"sort\" class=\"input w-auto cursor-pointer\" hx-get=\"/api/media-items/search\" hx-target=\"#books-grid\" hx-trigger=\"change\" hx-include=\"#filter-form\"><option value=\"title ASC\">Title (A-Z)</option> <option value=\"title DESC\">Title (Z-A)</option> <option value=\"author ASC\">Author (A-Z)</option> <option value=\"created_at DESC\">Date Added</option> <option value=\"page_count DESC\">Page Count</option></select> <button type=\"button\" @click=\"filtersOpen = true\" class=\"btn btn-secondary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> <input name=\"q\" type=\"text\" class=\"input pl-10\" placeholder=\"Search all fields…\"></div><select name=\"sort\" class=\"input w-auto cursor-pointer\" hx-get=\"/api/media-items/search\" hx-target=\"#books-grid\" hx-trigger=\"change\" hx-include=\"#filter-form\"><option value=\"title ASC\">Title (A-Z)</option> <option value=\"title DESC\">Title (Z-A)</option> <option value=\"author ASC\">Author (A-Z)</option> <option value=\"created_at DESC\">Date Added</option> <option value=\"page_count DESC\">Page Count</option></select> <button type=\"button\" @click=\"filtersOpen = true\" class=\"btn btn-secondary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -69,7 +91,7 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span>Filters</span></button> <button type=\"button\" @click=\"showSaveFilterModal()\" class=\"btn btn-secondary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span>Filters</span></button> <button type=\"button\" @click=\"showSaveFilterModal()\" class=\"btn btn-secondary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -77,7 +99,7 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span>Save</span></button><div class=\"relative\"><button type=\"button\" @click=\"toggleFiltersDropdown()\" data-load-filter-btn class=\"btn btn-secondary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span>Save</span></button><div class=\"relative\"><button type=\"button\" @click=\"toggleFiltersDropdown()\" data-load-filter-btn class=\"btn btn-secondary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -85,38 +107,38 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span>Load</span></button><div x-show=\"showFiltersDropdown\" @click.outside=\"showFiltersDropdown = false\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl shadow-lg z-50 card p-4\" x-cloak><h3 class=\"text-sm font-semibold mb-3\" style=\"color: var(--text-primary)\">Saved Filters</h3><div class=\"space-y-2\" id=\"saved-filters-list\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span>Load</span></button><div x-show=\"showFiltersDropdown\" @click.outside=\"showFiltersDropdown = false\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl shadow-lg z-50 card p-4\" x-cloak><h3 class=\"text-sm font-semibold mb-3\" style=\"color: var(--text-primary)\">Saved Filters</h3><div class=\"space-y-2\" id=\"saved-filters-list\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, filter := range savedFilters {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"flex items-center justify-between p-2 rounded-lg hover:bg-surface-hover\" data-filter-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"flex items-center justify-between p-2 rounded-lg hover:bg-surface-hover\" data-filter-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
|
||||
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: `bookshelf.templ`, Line: 93, Col: 135}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
_, 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, 8, "\"><button data-action=\"load-filter\" @click=\"loadFilter($event)\" class=\"flex-1 text-left px-2 py-1 rounded text-sm\" style=\"color: var(--text-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><button data-action=\"load-filter\" @click=\"loadFilter($event)\" class=\"flex-1 text-left px-2 py-1 rounded text-sm\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
|
||||
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: `bookshelf.templ`, Line: 95, Col: 25}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</button> <button data-action=\"delete-filter\" @click=\"deleteFilter($event)\" class=\"icon-btn h-8 w-8\" title=\"Delete filter\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</button> <button data-action=\"delete-filter\" @click=\"deleteFilter($event)\" class=\"icon-btn h-8 w-8\" title=\"Delete filter\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -124,22 +146,22 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</button></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(savedFilters) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"text-sm py-4 text-center\" style=\"color: var(--text-secondary);\">No saved filters yet</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"text-sm py-4 text-center\" style=\"color: var(--text-secondary);\">No saved filters yet</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></div><button type=\"button\" @click=\"clearFilters()\" class=\"btn btn-ghost\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div></div><button type=\"button\" @click=\"clearFilters()\" class=\"btn btn-ghost\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -147,7 +169,7 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span>Clear</span></button></div><!-- Filter drawer --><div x-show=\"filtersOpen\" x-cloak class=\"fixed inset-0 z-[65]\" @keydown.escape.window=\"filtersOpen = false\"><div class=\"absolute inset-0\" style=\"background-color: var(--surface-overlay);\" @click=\"filtersOpen = false\"></div><div class=\"absolute right-0 top-0 bottom-0 w-full max-w-md overflow-y-auto\" style=\"background-color: var(--bg-secondary); box-shadow: var(--shadow-pop);\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\"><div class=\"sticky top-0 flex items-center justify-between px-6 py-4 border-b\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Filters</h2><button type=\"button\" @click=\"filtersOpen = false\" class=\"icon-btn\" aria-label=\"Close filters\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span>Clear</span></button></div><!-- Filter drawer --><div x-show=\"filtersOpen\" x-cloak class=\"fixed inset-0 z-[65]\" @keydown.escape.window=\"filtersOpen = false\"><div class=\"absolute inset-0\" style=\"background-color: var(--surface-overlay);\" @click=\"filtersOpen = false\"></div><div class=\"absolute right-0 top-0 bottom-0 w-full max-w-md overflow-y-auto\" style=\"background-color: var(--bg-secondary); box-shadow: var(--shadow-pop);\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\"><div class=\"sticky top-0 flex items-center justify-between px-6 py-4 border-b\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Filters</h2><button type=\"button\" @click=\"filtersOpen = false\" class=\"icon-btn\" aria-label=\"Close filters\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -155,45 +177,27 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</button></div><div class=\"p-6 space-y-5\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library</label> <select id=\"library-select\" name=\"library_id\" class=\"input\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button></div><div class=\"p-6 space-y-5\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library</label><!-- Do NOT wire a `change` handler (or hx-trigger=\"change\")\n\t\t\t\t\t\t\t\t\t\ton this select: header.ts restoreLibrarySelection may\n\t\t\t\t\t\t\t\t\t\tfire a synthetic change on page load, and a handler\n\t\t\t\t\t\t\t\t\t\there would re-fetch the grid at offset 0, stomping\n\t\t\t\t\t\t\t\t\t\tSSR pagination (?offset=). User changes go through\n\t\t\t\t\t\t\t\t\t\tbookshelf.ts's own wiring only. --><select id=\"library-select\" name=\"library_id\" class=\"input\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(libraries) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<option value=\"\">No libraries available</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<option value=\"\">No libraries available</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
if currentLibraryID == "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<option value=\"\" selected>All Books (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<option value=\"\" selected>All Books (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||
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: 147, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 153, Col: 77}
|
||||
}
|
||||
_, 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, 18, ")</option> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<option value=\"\">All Books (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 149, Col: 68}
|
||||
}
|
||||
_, 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
|
||||
}
|
||||
@@ -201,190 +205,208 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<option value=\"\">All Books (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `bookshelf.templ`, Line: 155, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ")</option> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
if lib.Offline {
|
||||
if lib.ID == currentLibraryID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" selected>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
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: 154, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ") — storage offline</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<option value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" selected>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
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: 156, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
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: 156, Col: 48}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, ") — storage offline</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
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: 156, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
_, 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, 28, ") — storage offline</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `bookshelf.templ`, Line: 162, Col: 48}
|
||||
}
|
||||
_, 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, 29, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `bookshelf.templ`, Line: 162, Col: 68}
|
||||
}
|
||||
_, 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, 30, ") — storage offline</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
} else if lib.ID == currentLibraryID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 34}
|
||||
}
|
||||
_, 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, 30, "\" selected>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 56}
|
||||
}
|
||||
_, 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, 31, " (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
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: 159, Col: 76}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 34}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
_, 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, 32, ")</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<option value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" selected>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
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: 161, Col: 34}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
_, 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, 34, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
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: 161, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 76}
|
||||
}
|
||||
_, 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, 35, " (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ")</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
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: 161, Col: 67}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 34}
|
||||
}
|
||||
_, 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, 36, ")</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `bookshelf.templ`, Line: 167, Col: 47}
|
||||
}
|
||||
_, 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, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `bookshelf.templ`, Line: 167, Col: 67}
|
||||
}
|
||||
_, 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, ")</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Narrator</label> <input type=\"text\" name=\"narrator_filter\" placeholder=\"Filter by narrator\" class=\"input\" list=\"narrator-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchNarratorValues($el)\"> <datalist id=\"narrator-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Narrator</label> <input type=\"text\" name=\"narrator_filter\" placeholder=\"Filter by narrator\" class=\"input\" list=\"narrator-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchNarratorValues($el)\"> <datalist id=\"narrator-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -393,53 +415,53 @@ func BookShelf(
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if errorMessage != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
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: 287, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 293, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
_, 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, 39, "</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if count > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
|
||||
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: 296, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 302, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
_, 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, 42, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if offset <= 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " disabled")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " disabled")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -447,43 +469,43 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
||||
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: 305, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 311, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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))
|
||||
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: 310, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 316, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
_, 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, 47, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if offset+limit >= count {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " disabled")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " disabled")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "><span>Next</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "><span>Next</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -491,12 +513,12 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -504,7 +526,7 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
<body class={ "theme-" + user.Theme }>
|
||||
@Header(user, backUrl)
|
||||
<div class="app-subbar border-b" style="background-color: color-mix(in srgb, var(--bg-secondary) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-3">
|
||||
|
||||
@@ -41,13 +41,35 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(pageTitle)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 14, Col: 21}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 14, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||
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
|
||||
}
|
||||
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<body class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, 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, 4, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -55,20 +77,20 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"app-subbar border-b\" style=\"background-color: color-mix(in srgb, var(--bg-secondary) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);\"><div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-3\"><a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"app-subbar border-b\" style=\"background-color: color-mix(in srgb, var(--bg-secondary) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);\"><div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-3\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 templ.SafeURL
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl)
|
||||
var templ_7745c5c3_Var5 templ.SafeURL
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 22}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 22, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"btn btn-ghost\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"btn btn-ghost\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -76,77 +98,77 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 24, Col: 23}
|
||||
}
|
||||
_, 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, 6, "</span></a></div></div><div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"flex flex-wrap items-center gap-3 mb-6\"><span class=\"chip\" style=\"background-color: var(--accent); color: var(--bg-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 24, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></a></div></div><div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"flex flex-wrap items-center gap-3 mb-6\"><span class=\"chip\" style=\"background-color: var(--accent); color: var(--bg-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h1><span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 34, Col: 125}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 32}
|
||||
}
|
||||
_, 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, 10, " books</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 33, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</h1><span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 34, Col: 125}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " books</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(books) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -156,43 +178,43 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card text-center py-16 px-6\"><div class=\"text-5xl mb-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"card text-center py-16 px-6\"><div class=\"text-5xl mb-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 44, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
_, 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, 14, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Books Found</h3><p style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Books Found</h3><p style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 46, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 46, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
_, 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, 15, "</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -200,7 +222,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 23, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 23, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -85,7 +85,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 26, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 26, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -98,7 +98,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 32, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 32, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -111,7 +111,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 45, Col: 31}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 45, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -124,7 +124,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 58, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 58, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -137,7 +137,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 74, Col: 86}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 74, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -155,7 +155,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 135, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 135, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -11,7 +11,7 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }" x-data="collectionRules" x-init={ "initCollectionRules('" + collection.ID + "')" }>
|
||||
<body class={ "theme-" + user.Theme } x-data="collectionRules" x-init={ "initCollectionRules('" + collection.ID + "')" }>
|
||||
@Header(user, "/collections")
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-6">
|
||||
@@ -20,7 +20,7 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
Back to Collection
|
||||
</button>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</span>
|
||||
<span class="grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl" style={ "background-color: var(--accent-muted); color: " + collection.Color }>{ collection.Icon }</span>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||
<p class="flex items-center gap-2" style="color: var(--text-secondary)">
|
||||
|
||||
@@ -36,26 +36,48 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, 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: 9, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 9, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - 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 class=\"theme-{ user.Theme }\" x-data=\"collectionRules\" x-init=\"")
|
||||
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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, 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: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<body class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, 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, 4, "\" x-data=\"collectionRules\" x-init=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `collection_rules.templ`, Line: 14, Col: 120}
|
||||
}
|
||||
_, 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, 5, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -63,7 +85,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, 4, "<div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><button @click=\"backToCollection()\" class=\"btn btn-secondary mb-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><button @click=\"backToCollection()\" class=\"btn btn-secondary mb-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -71,33 +93,46 @@ 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, 5, "Back to Collection</button><div class=\"flex items-center gap-4\"><span class=\"grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl\" style=\"background-color: var(--accent-muted); color: { collection.Color }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Back to Collection</button><div class=\"flex items-center gap-4\"><span class=\"grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||
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: 176}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 159}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
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: 25, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 179}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</h1><p class=\"flex items-center gap-2\" style=\"color: var(--text-secondary)\"><span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> Rules evaluated live — the collection is always current, no refresh needed</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rules</h2><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">A book is in the collection while it matches ANY enabled rule. Saving here takes effect immediately, everywhere the collection renders.</p><div id=\"rules-container\" class=\"space-y-4\"><div id=\"no-rules\" class=\"card p-8 text-center\"><span class=\"inline-grid place-items-center h-12 w-12 rounded-xl mb-3\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span><div><h1 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(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 25, Col: 90}
|
||||
}
|
||||
_, 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, 10, "</h1><p class=\"flex items-center gap-2\" style=\"color: var(--text-secondary)\"><span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> Rules evaluated live — the collection is always current, no refresh needed</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rules</h2><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">A book is in the collection while it matches ANY enabled rule. Saving here takes effect immediately, everywhere the collection renders.</p><div id=\"rules-container\" class=\"space-y-4\"><div id=\"no-rules\" class=\"card p-8 text-center\"><span class=\"inline-grid place-items-center h-12 w-12 rounded-xl mb-3\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -105,20 +140,20 @@ 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, 8, "</span><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">No Rules Yet</h3><p style=\"color: var(--text-secondary)\">Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Create New Rule</h2><form id=\"rule-form\" @submit=\"handleCreateRule($event)\" class=\"card p-6 space-y-6\"><input type=\"hidden\" id=\"collection-id\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">No Rules Yet</h3><p style=\"color: var(--text-secondary)\">Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Create New Rule</h2><form id=\"rule-form\" @submit=\"handleCreateRule($event)\" class=\"card p-6 space-y-6\"><input type=\"hidden\" id=\"collection-id\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
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: `collection_rules.templ`, Line: 49, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
_, 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, "\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Field</label> <select id=\"rule-field\" required class=\"input\"><option value=\"\">Select field...</option> <option value=\"genre\">Genre</option> <option value=\"series\">Series</option> <option value=\"author\">Author</option> <option value=\"language\">Language</option> <option value=\"publisher\">Publisher</option> <option value=\"copyright_year\">Copyright Year</option> <option value=\"tags\">Tags</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Operator</label> <select id=\"rule-operator\" required class=\"input\"><option value=\"\">Select operator...</option> <option value=\"equals\">equals</option> <option value=\"not_equals\">does not equal</option> <option value=\"contains\">contains</option> <option value=\"not_contains\">does not contain</option> <option value=\"starts_with\">starts with</option> <option value=\"ends_with\">ends with</option> <option value=\"greater_than\">greater than</option> <option value=\"less_than\">less than</option></select></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Value</label> <input type=\"text\" id=\"rule-value\" required class=\"input\" placeholder=\"e.g., Science Fiction\"></div><div><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"rule-enabled\" checked class=\"w-5 h-5 rounded\"> <span class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">Enable Rule</span></label><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Uncheck to disable without deleting</p></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Priority</label><div class=\"flex gap-4\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"1\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">High</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"2\" checked class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Medium</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"3\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Low</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"testRule()\" class=\"btn btn-secondary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Field</label> <select id=\"rule-field\" required class=\"input\"><option value=\"\">Select field...</option> <option value=\"genre\">Genre</option> <option value=\"series\">Series</option> <option value=\"author\">Author</option> <option value=\"language\">Language</option> <option value=\"publisher\">Publisher</option> <option value=\"copyright_year\">Copyright Year</option> <option value=\"tags\">Tags</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Operator</label> <select id=\"rule-operator\" required class=\"input\"><option value=\"\">Select operator...</option> <option value=\"equals\">equals</option> <option value=\"not_equals\">does not equal</option> <option value=\"contains\">contains</option> <option value=\"not_contains\">does not contain</option> <option value=\"starts_with\">starts with</option> <option value=\"ends_with\">ends with</option> <option value=\"greater_than\">greater than</option> <option value=\"less_than\">less than</option></select></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Value</label> <input type=\"text\" id=\"rule-value\" required class=\"input\" placeholder=\"e.g., Science Fiction\"></div><div><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"rule-enabled\" checked class=\"w-5 h-5 rounded\"> <span class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">Enable Rule</span></label><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Uncheck to disable without deleting</p></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Priority</label><div class=\"flex gap-4\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"1\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">High</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"2\" checked class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Medium</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"3\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Low</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"testRule()\" class=\"btn btn-secondary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -126,7 +161,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, 10, "Test Rule</button> <button type=\"button\" @click=\"clearForm()\" class=\"btn btn-ghost\">Clear</button> <button type=\"submit\" class=\"btn btn-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Test Rule</button> <button type=\"button\" @click=\"clearForm()\" class=\"btn btn-ghost\">Clear</button> <button type=\"submit\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -134,7 +169,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, 11, "Add Rule</button></div></form><div id=\"test-results\" class=\"hidden card p-6 mt-4 mb-6\"><h3 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Rule Test Results</h3><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\">Books that would be added by this rule:</p><div id=\"test-results-list\" class=\"max-h-64 overflow-y-auto space-y-2\"></div></div></div><div class=\"card p-6\"><h2 class=\"text-xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rule Examples</h2><div class=\"space-y-4\"><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">1</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add all Science Fiction books</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: genre<br>Operator: equals<br>Value: Science Fiction</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">2</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books from a specific series</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: series<br>Operator: starts with<br>Value: Harry Potter</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">3</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books published in a year range</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: copyright_year<br>Operator: greater than<br>Value: 2020</code></div></div></div></div></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Add Rule</button></div></form><div id=\"test-results\" class=\"hidden card p-6 mt-4 mb-6\"><h3 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Rule Test Results</h3><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\">Books that would be added by this rule:</p><div id=\"test-results-list\" class=\"max-h-64 overflow-y-auto space-y-2\"></div></div></div><div class=\"card p-6\"><h2 class=\"text-xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rule Examples</h2><div class=\"space-y-4\"><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">1</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add all Science Fiction books</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: genre<br>Operator: equals<br>Value: Science Fiction</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">2</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books from a specific series</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: series<br>Operator: starts with<br>Value: Harry Potter</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">3</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books published in a year range</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: copyright_year<br>Operator: greater than<br>Value: 2020</code></div></div></div></div></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+78
-69
@@ -14,6 +14,76 @@ func pluralizeBooks(n int) string {
|
||||
return "books"
|
||||
}
|
||||
|
||||
// CollectionBooksGrid renders the content of #books-container: the
|
||||
// collection's book cards, or the empty state. Shared by the collection
|
||||
// detail page and the /collections/:id/books-grid partial route (the
|
||||
// library switcher refreshes the grid through it — rendering ownership:
|
||||
// templ renders, JS only swaps).
|
||||
templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) {
|
||||
if len(books) == 0 {
|
||||
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
||||
}
|
||||
for _, book := range books {
|
||||
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
|
||||
<div class="flex gap-4">
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<div class="flex-shrink-0 pt-1">
|
||||
<label class="flex items-center cursor-pointer p-2 -m-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-5 h-5"
|
||||
:checked={ "selectedBooks.includes(" + JSONArg(book.MediaItemID) + ")" }
|
||||
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href={ "/media/" + book.MediaItemID }>
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
|
||||
{ book.Title }
|
||||
</h3>
|
||||
</a>
|
||||
if book.Author != "" {
|
||||
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
|
||||
by { book.Author }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-16 sm:w-20">
|
||||
<a href={ "/media/" + book.MediaItemID }>
|
||||
if book.CoverImagePath != "" {
|
||||
<img
|
||||
src={ book.CoverImagePath }
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
/>
|
||||
} else {
|
||||
<img
|
||||
src="/static/placeholder-book.svg"
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||
/>
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||
<button
|
||||
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
|
||||
class="btn btn-secondary w-full"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Remove from Collection</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -25,7 +95,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
<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 }">
|
||||
<body x-data="collections" x-init="initCollectionsPage()" class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/collections")
|
||||
<div id="modal-container"></div>
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
@@ -140,7 +210,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
<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 }">
|
||||
<body x-data="collections" x-init="initCollectionsPage()" class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/collections")
|
||||
@LibrarySwitcher(libData, libraryID, "")
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
@@ -150,7 +220,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
<span>Back to Collections</span>
|
||||
</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="grid place-items-center h-14 w-14 rounded-2xl text-3xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</div>
|
||||
<div class="grid place-items-center h-14 w-14 rounded-2xl text-3xl" style={ "background-color: var(--accent-muted); color: " + collection.Color }>{ collection.Icon }</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2" style="color: var(--text-primary)">
|
||||
{ collection.Name }
|
||||
@@ -210,68 +280,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</div>
|
||||
</div>
|
||||
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
if len(books) == 0 {
|
||||
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
||||
}
|
||||
for _, book := range books {
|
||||
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
|
||||
<div class="flex gap-4">
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<div class="flex-shrink-0 pt-1">
|
||||
<label class="flex items-center cursor-pointer p-2 -m-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-5 h-5"
|
||||
:checked="selectedBooks.includes('{ book.MediaItemID }')"
|
||||
@change="toggleSelection('{ book.MediaItemID }')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href={ "/media/" + book.MediaItemID }>
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
|
||||
{ book.Title }
|
||||
</h3>
|
||||
</a>
|
||||
if book.Author != "" {
|
||||
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
|
||||
by { book.Author }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-16 sm:w-20">
|
||||
<a href={ "/media/" + book.MediaItemID }>
|
||||
if book.CoverImagePath != "" {
|
||||
<img
|
||||
src={ book.CoverImagePath }
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
/>
|
||||
} else {
|
||||
<img
|
||||
src="/static/placeholder-book.svg"
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||
/>
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||
<button
|
||||
@click="requestRemoveBook('{ book.MediaItemID }')"
|
||||
class="btn btn-secondary w-full"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Remove from Collection</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@CollectionBooksGrid(collection, books)
|
||||
</div>
|
||||
</div>
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
@@ -321,7 +330,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
name="q"
|
||||
placeholder="Search books..."
|
||||
class="input"
|
||||
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
|
||||
hx-get={ "/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID }
|
||||
hx-target="#book-picker-grid"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-include="#book-picker-filters"
|
||||
@@ -333,7 +342,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
name="author_filter"
|
||||
placeholder="Author"
|
||||
class="input"
|
||||
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
|
||||
hx-get={ "/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID }
|
||||
hx-target="#book-picker-grid"
|
||||
hx-trigger="change"
|
||||
hx-include="#book-picker-filters"
|
||||
@@ -345,7 +354,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
name="genre_filter"
|
||||
placeholder="Genre"
|
||||
class="input"
|
||||
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
|
||||
hx-get={ "/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID }
|
||||
hx-target="#book-picker-grid"
|
||||
hx-trigger="change"
|
||||
hx-include="#book-picker-filters"
|
||||
@@ -365,7 +374,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
<div
|
||||
id="book-picker-grid"
|
||||
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto"
|
||||
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
|
||||
hx-get={ "/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID }
|
||||
hx-trigger="loadBooks"
|
||||
hx-include="#book-picker-filters"
|
||||
>
|
||||
|
||||
+517
-335
File diff suppressed because it is too large.
Load diff
+89
-61
@@ -2,6 +2,89 @@ package templates
|
||||
|
||||
import "bookhoard/internal/handlers"
|
||||
|
||||
// ConflictsListContent renders the inner content of #conflicts-list:
|
||||
// bulk-select controls, the empty state, or the conflict cards. Shared by
|
||||
// the conflicts page and the /conflicts/list partial route.
|
||||
templ ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse) {
|
||||
<div id="select-all-container" class="mb-4 hidden">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" @change="toggleAllConflicts()"/>
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span>
|
||||
</label>
|
||||
</div>
|
||||
if len(conflicts) == 0 {
|
||||
<div class="card text-center py-16">
|
||||
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
|
||||
@Icon("check-circle", "h-7 w-7")
|
||||
</span>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
||||
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
|
||||
</div>
|
||||
}
|
||||
for _, conflict := range conflicts {
|
||||
<div class="conflict-item card p-6" data-conflict-id={ conflict.ID } data-status={ conflict.ResolutionStatus } style="border-left-width: 4px; border-left-color: var(--status-warning);">
|
||||
<div class="flex justify-between items-start mb-4 gap-4">
|
||||
<div class="flex items-start space-x-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="conflict-checkbox w-5 h-5 rounded mt-1"
|
||||
data-conflict-id={ conflict.ID }
|
||||
@change="updateBulkActions()"
|
||||
/>
|
||||
<div class="flex items-start gap-3">
|
||||
<span style="color: var(--status-warning);">
|
||||
@Icon("alert", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click={ "showResolveModal(" + JSONArg(conflict.ID) + ")" } class="btn btn-primary">Resolve</button>
|
||||
</div>
|
||||
<div class="text-sm" style="color: var(--text-secondary)">
|
||||
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
|
||||
if conflict.ResolvedBy != "" {
|
||||
| Resolved by: { conflict.ResolvedBy }
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<p id="filter-empty" class="hidden text-center p-4" style="color: var(--text-secondary)">
|
||||
No conflicts match this filter.
|
||||
</p>
|
||||
}
|
||||
|
||||
// ConflictStatsBar renders the total/unresolved/resolved stat cards.
|
||||
templ ConflictStatsBar(total int, unresolved int) {
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// ConflictsRefreshFragment is the /conflicts/list partial response: a
|
||||
// fresh list plus the stats bar as an out-of-band swap. The client swaps
|
||||
// this instead of re-rendering conflict cards from JSON (rendering
|
||||
// ownership: templ renders, JS only swaps).
|
||||
templ ConflictsRefreshFragment(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int) {
|
||||
<div id="conflicts-list" class="space-y-4">
|
||||
@ConflictsListContent(user, conflicts)
|
||||
</div>
|
||||
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg" hx-swap-oob="true">
|
||||
@ConflictStatsBar(total, unresolved)
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -13,7 +96,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body x-data="conflicts" class="theme-{ user.Theme }">
|
||||
<body x-data="conflicts" class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/conflicts")
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
@@ -30,7 +113,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
<div class="flex space-x-3">
|
||||
<select
|
||||
id="status-filter"
|
||||
onchange="filterConflicts()"
|
||||
@change="filterConflicts()"
|
||||
class="input"
|
||||
>
|
||||
<option value="unresolved">Unresolved Only</option>
|
||||
@@ -41,18 +124,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
</div>
|
||||
</div>
|
||||
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg">
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
|
||||
</div>
|
||||
@ConflictStatsBar(total, unresolved)
|
||||
</div>
|
||||
<div id="bulk-actions" class="mt-4 hidden">
|
||||
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
@@ -69,11 +141,11 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click="bulkResolve()" class="btn btn-primary text-sm">
|
||||
<button @click="resolveSelected()" class="btn btn-primary text-sm">
|
||||
@Icon("check", "h-4 w-4")
|
||||
Resolve Selected
|
||||
</button>
|
||||
<button @click="bulkDismiss()" class="btn btn-danger text-sm">
|
||||
<button @click="dismissSelected()" class="btn btn-danger text-sm">
|
||||
Dismiss Selected
|
||||
</button>
|
||||
</div>
|
||||
@@ -92,51 +164,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
|
||||
</div>
|
||||
<div id="conflicts-list" class="space-y-4">
|
||||
<div id="select-all-container" class="mb-4 hidden">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span>
|
||||
</label>
|
||||
</div>
|
||||
if len(conflicts) == 0 {
|
||||
<div class="card text-center py-16">
|
||||
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
|
||||
@Icon("check-circle", "h-7 w-7")
|
||||
</span>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
||||
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
|
||||
</div>
|
||||
}
|
||||
for _, conflict := range conflicts {
|
||||
<div class="conflict-item card p-6" data-conflict-id={ conflict.ID } style="border-left-width: 4px; border-left-color: var(--status-warning);">
|
||||
<div class="flex justify-between items-start mb-4 gap-4">
|
||||
<div class="flex items-start space-x-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="conflict-checkbox w-5 h-5 rounded mt-1"
|
||||
data-conflict-id={ conflict.ID }
|
||||
onchange="updateBulkActions()"
|
||||
/>
|
||||
<div class="flex items-start gap-3">
|
||||
<span style="color: var(--status-warning);">
|
||||
@Icon("alert", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="showResolveModal('{ conflict.ID }')" class="btn btn-primary">Resolve</button>
|
||||
</div>
|
||||
<div class="text-sm" style="color: var(--text-secondary)">
|
||||
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
|
||||
if conflict.ResolvedBy != "" {
|
||||
| Resolved by: { conflict.ResolvedBy }
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@ConflictsListContent(user, conflicts)
|
||||
</div>
|
||||
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-4xl my-8" style="box-shadow: var(--shadow-pop);">
|
||||
|
||||
+291
-116
@@ -10,7 +10,10 @@ import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "bookhoard/internal/handlers"
|
||||
|
||||
func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) templ.Component {
|
||||
// ConflictsListContent renders the inner content of #conflicts-list:
|
||||
// bulk-select controls, the empty state, or the conflict cards. Shared by
|
||||
// the conflicts page and the /conflicts/list partial route.
|
||||
func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse) 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 {
|
||||
@@ -31,83 +34,12 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
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>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><body x-data=\"conflicts\" class=\"theme-{ user.Theme }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Header(user, "/conflicts").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex justify-between items-center gap-4 flex-wrap\"><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: color-mix(in srgb, var(--status-warning) 16%, transparent); 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, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Sync Conflicts</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve conflicts when reading progress differs across devices</p></div><div class=\"flex space-x-3\"><select id=\"status-filter\" onchange=\"filterConflicts()\" class=\"input\"><option value=\"unresolved\">Unresolved Only</option> <option value=\"all\">All Conflicts</option> <option value=\"resolved\">Resolved Only</option></select> <button @click=\"dismissAllResolved()\" class=\"btn btn-secondary\">Dismiss All Resolved</button></div></div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-3 gap-4 max-w-lg\"><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" 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(total)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 45, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Total</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-warning);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 49, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Unresolved</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-success);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 53, Col: 97}
|
||||
}
|
||||
_, 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, 6, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Resolved</div></div></div><div id=\"bulk-actions\" class=\"mt-4 hidden\"><div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div class=\"flex items-center space-x-4\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\"><span id=\"selected-count\">0</span> conflicts selected</span> <select id=\"bulk-strategy\" class=\"input w-auto\"><option value=\"most_recent\">Most Recent</option> <option value=\"highest_progress\">Highest Progress</option></select></div><div class=\"flex space-x-2\"><button @click=\"bulkResolve()\" class=\"btn btn-primary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("check", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Resolve Selected</button> <button @click=\"bulkDismiss()\" class=\"btn btn-danger text-sm\">Dismiss Selected</button></div></div></div></div><div id=\"loading\" class=\"hidden text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading conflicts...</p></div><div id=\"empty-state\" class=\"hidden card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("check-circle", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div><div id=\"conflicts-list\" class=\"space-y-4\"><div id=\"select-all-container\" class=\"mb-4 hidden\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"checkbox\" id=\"select-all-conflicts\" class=\"w-5 h-5 rounded\" onchange=\"toggleAllConflicts()\"> <span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Select All</span></label></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"select-all-container\" class=\"mb-4 hidden\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"checkbox\" id=\"select-all-conflicts\" class=\"w-5 h-5 rounded\" @change=\"toggleAllConflicts()\"> <span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Select All</span></label></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(conflicts) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -115,39 +47,52 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
for _, conflict := range conflicts {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"conflict-item card p-6\" data-conflict-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"conflict-item card p-6\" data-conflict-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 111, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 25, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" style=\"border-left-width: 4px; border-left-color: var(--status-warning);\"><div class=\"flex justify-between items-start mb-4 gap-4\"><div class=\"flex items-start space-x-4\"><input type=\"checkbox\" class=\"conflict-checkbox w-5 h-5 rounded mt-1\" data-conflict-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" data-status=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ResolutionStatus)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 117, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 25, Col: 114}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" onchange=\"updateBulkActions()\"><div class=\"flex items-start gap-3\"><span style=\"color: var(--status-warning);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" style=\"border-left-width: 4px; border-left-color: var(--status-warning);\"><div class=\"flex justify-between items-start mb-4 gap-4\"><div class=\"flex items-start space-x-4\"><input type=\"checkbox\" class=\"conflict-checkbox w-5 h-5 rounded mt-1\" data-conflict-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 31, Col: 40}
|
||||
}
|
||||
_, 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, 7, "\" @change=\"updateBulkActions()\"><div class=\"flex items-start gap-3\"><span style=\"color: var(--status-warning);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -155,70 +100,300 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span><div><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><div><h3 class=\"text-lg font-semibold\" 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(conflict.MediaItemTitle)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 39, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Type: ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 40, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p></div></div></div><button @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("showResolveModal(" + JSONArg(conflict.ID) + ")")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 125, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 44, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
_, 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, 15, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Type: ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"btn btn-primary\">Resolve</button></div><div class=\"text-sm\" style=\"color: var(--text-secondary)\">Created: ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 126, Col: 96}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 47, Col: 70}
|
||||
}
|
||||
_, 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, 16, "</p></div></div></div><button @click=\"showResolveModal('{ conflict.ID }')\" class=\"btn btn-primary\">Resolve</button></div><div class=\"text-sm\" style=\"color: var(--text-secondary)\">Created: ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 133, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if conflict.ResolvedBy != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "| Resolved by: ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "| Resolved by: ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 135, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div><div id=\"conflict-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-4xl my-8\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Resolve Conflict</h2><p class=\"text-sm\" id=\"conflict-book-title\" style=\"color: var(--text-secondary);\">Book Title</p></div><button @click=\"hideResolveModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p id=\"filter-empty\" class=\"hidden text-center p-4\" style=\"color: var(--text-secondary)\">No conflicts match this filter.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ConflictStatsBar renders the total/unresolved/resolved stat cards.
|
||||
func ConflictStatsBar(total int, unresolved int) 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_Var10 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var10 == nil {
|
||||
templ_7745c5c3_Var10 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" 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(total)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 62, Col: 82}
|
||||
}
|
||||
_, 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, 17, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Total</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-warning);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 66, Col: 89}
|
||||
}
|
||||
_, 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, 18, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Unresolved</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-success);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 70, Col: 97}
|
||||
}
|
||||
_, 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, 19, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Resolved</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ConflictsRefreshFragment is the /conflicts/list partial response: a
|
||||
// fresh list plus the stats bar as an out-of-band swap. The client swaps
|
||||
// this instead of re-rendering conflict cards from JSON (rendering
|
||||
// ownership: templ renders, JS only swaps).
|
||||
func ConflictsRefreshFragment(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int) 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_Var14 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var14 == nil {
|
||||
templ_7745c5c3_Var14 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div id=\"conflicts-list\" class=\"space-y-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = ConflictsListContent(user, conflicts).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-3 gap-4 max-w-lg\" hx-swap-oob=\"true\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = ConflictStatsBar(total, unresolved).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_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, 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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 = []any{"theme-" + user.Theme}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<body x-data=\"conflicts\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var16).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, 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, 25, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Header(user, "/conflicts").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex justify-between items-center gap-4 flex-wrap\"><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: color-mix(in srgb, var(--status-warning) 16%, transparent); 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, 27, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Sync Conflicts</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve conflicts when reading progress differs across devices</p></div><div class=\"flex space-x-3\"><select id=\"status-filter\" @change=\"filterConflicts()\" class=\"input\"><option value=\"unresolved\">Unresolved Only</option> <option value=\"all\">All Conflicts</option> <option value=\"resolved\">Resolved Only</option></select> <button @click=\"dismissAllResolved()\" class=\"btn btn-secondary\">Dismiss All Resolved</button></div></div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-3 gap-4 max-w-lg\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = ConflictStatsBar(total, unresolved).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div id=\"bulk-actions\" class=\"mt-4 hidden\"><div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div class=\"flex items-center space-x-4\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\"><span id=\"selected-count\">0</span> conflicts selected</span> <select id=\"bulk-strategy\" class=\"input w-auto\"><option value=\"most_recent\">Most Recent</option> <option value=\"highest_progress\">Highest Progress</option></select></div><div class=\"flex space-x-2\"><button @click=\"resolveSelected()\" class=\"btn btn-primary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("check", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Resolve Selected</button> <button @click=\"dismissSelected()\" class=\"btn btn-danger text-sm\">Dismiss Selected</button></div></div></div></div><div id=\"loading\" class=\"hidden text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading conflicts...</p></div><div id=\"empty-state\" class=\"hidden card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("check-circle", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div><div id=\"conflicts-list\" class=\"space-y-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = ConflictsListContent(user, conflicts).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><div id=\"conflict-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-4xl my-8\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Resolve Conflict</h2><p class=\"text-sm\" id=\"conflict-book-title\" style=\"color: var(--text-secondary);\">Book Title</p></div><button @click=\"hideResolveModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -226,7 +401,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</button></div><form id=\"conflict-resolution-form\" @submit=\"handleResolveSubmit($event)\"><input type=\"hidden\" id=\"conflict-id\"><div id=\"conflict-details\" class=\"mb-6\"></div><div class=\"card p-4 mb-6\"><h3 class=\"font-semibold mb-4\" style=\"color: var(--text-primary)\">Resolution Options</h3><div class=\"space-y-3 mb-6\"><label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"koreader\" class=\"w-4 h-4\" required><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep KOReader Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your KOReader device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"kobo\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Kobo Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your Kobo device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"web\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Web Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from the web interface</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"manual\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Manual Override</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Specify a custom progress value</p></div></label></div><div id=\"manual-override\" class=\"hidden mb-4 p-4 rounded-lg\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary);\">Manual Progress</h4><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Percentage (0-100)</label> <input type=\"number\" id=\"manual-percentage\" min=\"0\" max=\"100\" step=\"0.01\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Page Number</label> <input type=\"number\" id=\"manual-page\" min=\"1\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">EPUB CFI</label> <input type=\"text\" id=\"manual-epubcfi\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Chapter</label> <input type=\"number\" id=\"manual-chapter\" min=\"1\" class=\"input\"></div></div></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"apply-to-all\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary);\">Apply this resolution to all future conflicts</span></label></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Resolution Reason (optional)</label> <input type=\"text\" id=\"resolution-reason\" class=\"input\" placeholder=\"e.g., Device was offline, using most recent progress\"></div></div><div class=\"flex justify-end space-x-3 flex-wrap\"><button type=\"button\" @click=\"hideResolveModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"deleteConflict()\" class=\"btn btn-danger\">Dismiss</button> <button type=\"submit\" class=\"btn btn-primary\">Resolve Conflict</button></div></form></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</button></div><form id=\"conflict-resolution-form\" @submit=\"handleResolveSubmit($event)\"><input type=\"hidden\" id=\"conflict-id\"><div id=\"conflict-details\" class=\"mb-6\"></div><div class=\"card p-4 mb-6\"><h3 class=\"font-semibold mb-4\" style=\"color: var(--text-primary)\">Resolution Options</h3><div class=\"space-y-3 mb-6\"><label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"koreader\" class=\"w-4 h-4\" required><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep KOReader Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your KOReader device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"kobo\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Kobo Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your Kobo device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"web\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Web Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from the web interface</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"manual\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Manual Override</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Specify a custom progress value</p></div></label></div><div id=\"manual-override\" class=\"hidden mb-4 p-4 rounded-lg\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary);\">Manual Progress</h4><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Percentage (0-100)</label> <input type=\"number\" id=\"manual-percentage\" min=\"0\" max=\"100\" step=\"0.01\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Page Number</label> <input type=\"number\" id=\"manual-page\" min=\"1\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">EPUB CFI</label> <input type=\"text\" id=\"manual-epubcfi\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Chapter</label> <input type=\"number\" id=\"manual-chapter\" min=\"1\" class=\"input\"></div></div></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"apply-to-all\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary);\">Apply this resolution to all future conflicts</span></label></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Resolution Reason (optional)</label> <input type=\"text\" id=\"resolution-reason\" class=\"input\" placeholder=\"e.g., Device was offline, using most recent progress\"></div></div><div class=\"flex justify-end space-x-3 flex-wrap\"><button type=\"button\" @click=\"hideResolveModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"deleteConflict()\" class=\"btn btn-danger\">Dismiss</button> <button type=\"submit\" class=\"btn btn-primary\">Resolve Conflict</button></div></form></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -234,7 +409,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
<body class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/custom-section")
|
||||
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8 max-w-4xl">
|
||||
<div class="mb-8">
|
||||
|
||||
@@ -29,7 +29,29 @@ 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><body class=\"theme-{ user.Theme }\">")
|
||||
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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 = []any{"theme-" + user.Theme}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `custom_section.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -37,7 +59,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8 max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8 max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -45,7 +67,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Create Custom Section</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Build a custom dashboard section by defining filter rules or manually selecting books.</p></div><form id=\"custom-section-form\" class=\"space-y-6\"><div class=\"card p-6\"><h2 class=\"text-lg font-semibold mb-4 flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Create Custom Section</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Build a custom dashboard section by defining filter rules or manually selecting books.</p></div><form id=\"custom-section-form\" class=\"space-y-6\"><div class=\"card p-6\"><h2 class=\"text-lg font-semibold mb-4 flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -53,43 +75,43 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Section Details</h2><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label for=\"section-name\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Name *</label> <input type=\"text\" id=\"section-name\" name=\"name\" required class=\"input\"></div><div><label for=\"section-icon\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon (emoji)</label> <input type=\"text\" id=\"section-icon\" name=\"icon\" maxlength=\"4\" class=\"input\" placeholder=\"📚\"></div></div><div class=\"mt-4\"><label for=\"section-description\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea id=\"section-description\" name=\"description\" rows=\"2\" class=\"input\"></textarea></div><div class=\"mt-4\"><label for=\"section-library\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Library *</label> <select id=\"section-library\" name=\"library_id\" required class=\"input\"><option value=\"\">Select a library…</option> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Section Details</h2><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label for=\"section-name\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Name *</label> <input type=\"text\" id=\"section-name\" name=\"name\" required class=\"input\"></div><div><label for=\"section-icon\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon (emoji)</label> <input type=\"text\" id=\"section-icon\" name=\"icon\" maxlength=\"4\" class=\"input\" placeholder=\"📚\"></div></div><div class=\"mt-4\"><label for=\"section-description\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea id=\"section-description\" name=\"description\" rows=\"2\" class=\"input\"></textarea></div><div class=\"mt-4\"><label for=\"section-library\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Library *</label> <select id=\"section-library\" name=\"library_id\" required class=\"input\"><option value=\"\">Select a library…</option> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<option value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
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: `custom_section.templ`, Line: 74, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
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: `custom_section.templ`, Line: 74, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</select></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</select></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -97,7 +119,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Filter Rules</h2><button type=\"button\" id=\"add-rule-btn\" class=\"btn btn-primary text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Filter Rules</h2><button type=\"button\" id=\"add-rule-btn\" class=\"btn btn-primary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -105,7 +127,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Add Rule</button></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.</p><div id=\"rules-container\" class=\"space-y-3\"></div><div class=\"mt-4 flex items-center gap-2\"><label for=\"match-type\" class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Match:</label> <select id=\"match-type\" name=\"match_type\" class=\"input w-auto\"><option value=\"all\">ALL rules (AND)</option> <option value=\"any\">ANY rule (OR)</option></select></div></div><div class=\"card p-6\"><h2 class=\"text-lg font-semibold mb-4 flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Add Rule</button></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.</p><div id=\"rules-container\" class=\"space-y-3\"></div><div class=\"mt-4 flex items-center gap-2\"><label for=\"match-type\" class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Match:</label> <select id=\"match-type\" name=\"match_type\" class=\"input w-auto\"><option value=\"all\">ALL rules (AND)</option> <option value=\"any\">ANY rule (OR)</option></select></div></div><div class=\"card p-6\"><h2 class=\"text-lg font-semibold mb-4 flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -113,7 +135,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Manual Book Selection</h2><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Add specific books to this section. Use the search to find and select multiple books.</p><div class=\"mb-4\"><label for=\"book-search\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search Books</label><div class=\"flex gap-2\"><input type=\"text\" id=\"book-search\" name=\"book_search\" class=\"input flex-1\" placeholder=\"Search by title or author…\" autocomplete=\"off\"> <button type=\"button\" id=\"search-books-btn\" class=\"btn btn-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Manual Book Selection</h2><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Add specific books to this section. Use the search to find and select multiple books.</p><div class=\"mb-4\"><label for=\"book-search\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search Books</label><div class=\"flex gap-2\"><input type=\"text\" id=\"book-search\" name=\"book_search\" class=\"input flex-1\" placeholder=\"Search by title or author…\" autocomplete=\"off\"> <button type=\"button\" id=\"search-books-btn\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -121,7 +143,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-xl border-2 border-dashed\" style=\"border-color: var(--border-strong); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-xl border-2 border-dashed\" style=\"border-color: var(--border-strong); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -129,7 +151,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Live Preview</h2><button type=\"button\" id=\"preview-btn\" class=\"btn btn-secondary text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Live Preview</h2><button type=\"button\" id=\"preview-btn\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -137,7 +159,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Refresh Preview</button></div><div id=\"preview-container\" class=\"card p-4\" style=\"background-color: var(--bg-primary); min-height: 200px;\"><p class=\"text-center text-sm\" style=\"color: var(--text-secondary);\">Add filter rules or select books to see a preview of your custom section.</p></div></div><div class=\"flex justify-end gap-3\"><button type=\"button\" id=\"cancel-btn\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" id=\"save-section-btn\" class=\"btn btn-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Refresh Preview</button></div><div id=\"preview-container\" class=\"card p-4\" style=\"background-color: var(--bg-primary); min-height: 200px;\"><p class=\"text-center text-sm\" style=\"color: var(--text-secondary);\">Add filter rules or select books to see a preview of your custom section.</p></div></div><div class=\"flex justify-end gap-3\"><button type=\"button\" id=\"cancel-btn\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" id=\"save-section-btn\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -145,7 +167,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Save Section</button></div></form></main>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Save Section</button></div></form></main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -153,7 +175,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string) {
|
||||
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string, unlinkedCount int) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -16,13 +16,31 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
||||
<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 }">
|
||||
<body x-data="dashboard" x-init="initDashboard()" class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/dashboard")
|
||||
@LibrarySwitcher(libData, currentLibraryID, "Your Library", 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
|
||||
switch. The widget re-fetches itself via htmx instead. -->
|
||||
if unlinkedCount > 0 {
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 pt-4">
|
||||
<a
|
||||
href="/unlinked-books"
|
||||
class="card p-4 flex items-center gap-3 transition-colors hover:bg-surface-hover"
|
||||
style="border-left-width: 4px; border-left-color: var(--status-warning);"
|
||||
>
|
||||
<span class="grid place-items-center h-8 w-8 rounded-lg shrink-0" style="background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);">
|
||||
@Icon("alert", "h-4 w-4")
|
||||
</span>
|
||||
<span class="flex-1 text-sm" style="color: var(--text-primary)">
|
||||
<span class="font-semibold">{ fmt.Sprintf("%d", unlinkedCount) }</span>
|
||||
{ pluralizeBooks(unlinkedCount) } on your devices could not be identified — reading progress for them is paused until they are linked.
|
||||
</span>
|
||||
<span class="text-sm font-medium shrink-0" style="color: var(--accent)">Review →</span>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
if week != nil {
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 pt-6">
|
||||
@DashboardWeekWidget(*week, activeLibType)
|
||||
@@ -227,7 +245,7 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
||||
data-action="restore-system-collection"
|
||||
data-collection-name={ section.ID }
|
||||
class="btn btn-secondary py-1 px-2.5 text-xs"
|
||||
title="Restore { section.Title } to defaults"
|
||||
title={ "Restore " + section.Title + " to defaults" }
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
|
||||
+470
-395
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,7 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }" x-data="devices" x-init="setupEventDelegation()">
|
||||
<body class={ "theme-" + user.Theme } x-data="devices" x-init="setupEventDelegation()">
|
||||
@Header(user, "/devices")
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
@@ -105,13 +105,13 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
<div class="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
id="sync-url-{ device.ID }"
|
||||
id={ "sync-url-" + device.ID.String() }
|
||||
readonly
|
||||
value={ baseURL + "/api/sync/kobo/" + device.AuthToken }
|
||||
class="input text-xs font-mono"
|
||||
/>
|
||||
<button
|
||||
@click="copyToClipboard(document.getElementById('sync-url-{ device.ID }').value, 'Kobo sync URL')"
|
||||
@click={ "copyToClipboard(document.getElementById('sync-url-" + device.ID.String() + "').value, 'Kobo sync URL')" }
|
||||
class="btn btn-primary text-xs px-3"
|
||||
>
|
||||
@Icon("copy", "h-4 w-4")
|
||||
@@ -127,13 +127,13 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
<div class="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
id="auth-token-{ device.ID }"
|
||||
id={ "auth-token-" + device.ID.String() }
|
||||
readonly
|
||||
value={ device.AuthToken }
|
||||
class="input text-xs font-mono"
|
||||
/>
|
||||
<button
|
||||
@click="copyToClipboard(document.getElementById('auth-token-{ device.ID }').value, 'Auth token')"
|
||||
@click={ "copyToClipboard(document.getElementById('auth-token-" + device.ID.String() + "').value, 'Auth token')" }
|
||||
class="btn btn-primary text-xs px-3"
|
||||
>
|
||||
@Icon("copy", "h-4 w-4")
|
||||
|
||||
+234
-160
@@ -31,7 +31,29 @@ 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><body class=\"theme-{ user.Theme }\" x-data=\"devices\" x-init=\"setupEventDelegation()\">")
|
||||
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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 = []any{"theme-" + user.Theme}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" x-data=\"devices\" x-init=\"setupEventDelegation()\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -39,7 +61,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex justify-between items-center gap-4 flex-wrap\"><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, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex justify-between items-center gap-4 flex-wrap\"><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
|
||||
}
|
||||
@@ -47,7 +69,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Device Management</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage your reading devices and sync settings</p></div><button @click=\"showAddDeviceModal()\" class=\"btn btn-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Device Management</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage your reading devices and sync settings</p></div><button @click=\"showAddDeviceModal()\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -55,12 +77,12 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Add New Device</button></div></div><div id=\"devices-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Add New Device</button></div></div><div id=\"devices-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div id=\"empty-state\" class=\"card text-center py-16 col-span-full\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div id=\"empty-state\" class=\"card text-center py-16 col-span-full\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -68,18 +90,18 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Devices Yet</h3><p class=\"mb-4 text-sm\" style=\"color: var(--text-secondary)\">Add your reading devices to enable cross-device sync</p><button @click=\"showAddDeviceModal()\" class=\"btn btn-primary\">Add Your First Device</button></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Devices Yet</h3><p class=\"mb-4 text-sm\" style=\"color: var(--text-secondary)\">Add your reading devices to enable cross-device sync</p><button @click=\"showAddDeviceModal()\" class=\"btn btn-primary\">Add Your First Device</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(devices) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div id=\"devices-grid\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 col-span-full\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div id=\"devices-grid\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 col-span-full\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, device := range devices {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"card p-6\"><div class=\"flex items-start justify-between mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"card p-6\"><div class=\"flex items-start justify-between mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -104,20 +126,20 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span><div class=\"flex space-x-1\"><button @click=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span><div class=\"flex space-x-1\"><button @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("showShelfMappings('" + device.ID.String() + "')")
|
||||
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: `devices.templ`, Line: 64, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
_, 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, 10, "\" class=\"icon-btn\" title=\"Shelf mappings\" aria-label=\"Shelf mappings\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"icon-btn\" title=\"Shelf mappings\" aria-label=\"Shelf mappings\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -125,20 +147,20 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</button> <button @click=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</button> <button @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')")
|
||||
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: `devices.templ`, Line: 67, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
_, 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, 12, "\" class=\"icon-btn\" title=\"Device settings\" aria-label=\"Device settings\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"icon-btn\" title=\"Device settings\" aria-label=\"Device settings\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -146,122 +168,148 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</button></div></div><h3 class=\"text-lg font-semibold mb-1\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</button></div></div><h3 class=\"text-lg font-semibold mb-1\" 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(device.DeviceName)
|
||||
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: `devices.templ`, Line: 72, Col: 102}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</h3><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</h3><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType)
|
||||
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: `devices.templ`, Line: 73, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p><div class=\"space-y-2 text-sm mb-4\"><div class=\"flex justify-between\"><span style=\"color: var(--text-secondary)\">Sync Status</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</p><div class=\"space-y-2 text-sm mb-4\"><div class=\"flex justify-between\"><span style=\"color: var(--text-secondary)\">Sync Status</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if device.SyncEnabled {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"badge status-completed\">Enabled</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"badge status-completed\">Enabled</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"badge\" style=\"background-color: var(--surface-hover); color: var(--text-secondary);\">Disabled</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"badge\" style=\"background-color: var(--surface-hover); color: var(--text-secondary);\">Disabled</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div><div class=\"flex justify-between\"><span style=\"color: var(--text-secondary)\">Last Sync</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div><div class=\"flex justify-between\"><span style=\"color: var(--text-secondary)\">Last Sync</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if device.LastSync != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span 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(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}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span style=\"color: var(--text-secondary)\">Never</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div><div class=\"flex justify-between\"><span style=\"color: var(--text-secondary)\">Last Seen</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if device.LastSeen != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, 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}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span style=\"color: var(--text-secondary)\">Never</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div><div class=\"border-t pt-4\" style=\"border-color: var(--border);\"><p class=\"text-xs font-semibold uppercase tracking-wide mb-3\" style=\"color: var(--text-secondary)\">Device Sync Configuration</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if device.DeviceType == "kobo" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"mb-3\"><label class=\"block text-xs mb-1\" style=\"color: var(--text-secondary)\">Kobo Sync URL</label><div class=\"flex space-x-2\"><input type=\"text\" id=\"sync-url-{ device.ID }\" readonly value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span 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.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken)
|
||||
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: 110, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 86, Col: 104}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
_, 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, 28, "\" class=\"input text-xs font-mono\"> <button @click=\"copyToClipboard(document.getElementById('sync-url-{ device.ID }').value, 'Kobo sync URL')\" class=\"btn btn-primary text-xs px-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span style=\"color: var(--text-secondary)\">Never</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div><div class=\"flex justify-between\"><span style=\"color: var(--text-secondary)\">Last Seen</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if device.LastSeen != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `devices.templ`, Line: 94, Col: 104}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span style=\"color: var(--text-secondary)\">Never</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div></div><div class=\"border-t pt-4\" style=\"border-color: var(--border);\"><p class=\"text-xs font-semibold uppercase tracking-wide mb-3\" style=\"color: var(--text-secondary)\">Device Sync Configuration</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if device.DeviceType == "kobo" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"mb-3\"><label class=\"block text-xs mb-1\" style=\"color: var(--text-secondary)\">Kobo Sync URL</label><div class=\"flex space-x-2\"><input type=\"text\" id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `devices.templ`, Line: 108, Col: 51}
|
||||
}
|
||||
_, 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, 30, "\" readonly value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `devices.templ`, Line: 110, Col: 68}
|
||||
}
|
||||
_, 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, 31, "\" class=\"input text-xs font-mono\"> <button @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard(document.getElementById('sync-url-" + device.ID.String() + "').value, 'Kobo sync URL')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 114, Col: 127}
|
||||
}
|
||||
_, 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, 32, "\" class=\"btn btn-primary text-xs px-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -269,26 +317,52 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Copy</button></div><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary);\">Paste this URL into Kobo's <code class=\"px-1 py-0.5 rounded\" style=\"background-color: var(--bg-primary);\">api_endpoint</code> setting</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "Copy</button></div><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary);\">Paste this URL into Kobo's <code class=\"px-1 py-0.5 rounded\" style=\"background-color: var(--bg-primary);\">api_endpoint</code> setting</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if device.DeviceType == "koreader" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"mb-3\"><label class=\"block text-xs mb-1\" style=\"color: var(--text-secondary)\">Auth Token (for plugin)</label><div class=\"flex space-x-2\"><input type=\"text\" id=\"auth-token-{ device.ID }\" readonly value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div class=\"mb-3\"><label class=\"block text-xs mb-1\" style=\"color: var(--text-secondary)\">Auth Token (for plugin)</label><div class=\"flex space-x-2\"><input type=\"text\" id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken)
|
||||
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: 132, Col: 38}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 130, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"input text-xs font-mono\"> <button @click=\"copyToClipboard(document.getElementById('auth-token-{ device.ID }').value, 'Auth token')\" class=\"btn btn-primary text-xs px-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" readonly value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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: `devices.templ`, Line: 132, Col: 38}
|
||||
}
|
||||
_, 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, 36, "\" class=\"input text-xs font-mono\"> <button @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard(document.getElementById('auth-token-" + device.ID.String() + "').value, 'Auth token')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 136, Col: 126}
|
||||
}
|
||||
_, 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, 37, "\" class=\"btn btn-primary text-xs px-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -296,25 +370,25 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Copy</button></div><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary);\">Enter this token in the KOReader plugin settings</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Copy</button></div><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary);\">Enter this token in the KOReader plugin settings</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button hx-put=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<button hx-put=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token")
|
||||
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: `devices.templ`, Line: 147, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
_, 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, 34, "\" hx-confirm=\"Old token will immediately stop working. Regenerate anyway?\" class=\"btn btn-secondary w-full text-xs\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" hx-confirm=\"Old token will immediately stop working. Regenerate anyway?\" class=\"btn btn-secondary w-full text-xs\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -322,102 +396,102 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "Regenerate Token</button><p class=\"text-xs mt-1\" style=\"color: var(--status-warning);\">Old token will immediately stop working</p></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "Regenerate Token</button><p class=\"text-xs mt-1\" style=\"color: var(--status-warning);\">Old token will immediately stop working</p></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(pendingRegistrations) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div id=\"pending-section\" class=\"mt-12\"><h2 class=\"text-lg font-bold tracking-tight mb-4\" style=\"color: var(--text-primary)\">Pending Device Registrations</h2><div class=\"space-y-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div id=\"pending-section\" class=\"mt-12\"><h2 class=\"text-lg font-bold tracking-tight mb-4\" style=\"color: var(--text-primary)\">Pending Device Registrations</h2><div class=\"space-y-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, reg := range pendingRegistrations {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div><h3 class=\"font-semibold\" 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(reg.DeviceName)
|
||||
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: `devices.templ`, Line: 168, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
_, 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, 40, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</h3><p class=\"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(reg.DeviceType)
|
||||
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: `devices.templ`, Line: 170, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
_, 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, 41, " - Expires in ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " - Expires in ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt)
|
||||
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: `devices.templ`, Line: 170, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
_, 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, 42, "</p></div><div class=\"flex space-x-2\"><button @click=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</p></div><div class=\"flex space-x-2\"><button @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')")
|
||||
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: `devices.templ`, Line: 174, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
_, 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, 43, "\" class=\"btn btn-primary text-sm\">Approve</button> <button @click=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" class=\"btn btn-primary text-sm\">Approve</button> <button @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')")
|
||||
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: `devices.templ`, Line: 177, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
_, 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, 44, "\" class=\"btn btn-danger text-sm\">Reject</button></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" class=\"btn btn-danger text-sm\">Reject</button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div id=\"sync-queue-section\" class=\"mt-12 hidden\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-lg font-bold tracking-tight\" style=\"color: var(--text-primary)\">Sync Queue</h2><button @click=\"clearSyncQueue()\" class=\"btn btn-secondary\">Clear Queue</button></div><div id=\"sync-queue\" class=\"space-y-3\"></div></div></div><div id=\"add-device-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" x-data=\"{ selectedType: '' }\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add New Device</h2><button @click=\"hideAddDeviceModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div id=\"sync-queue-section\" class=\"mt-12 hidden\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-lg font-bold tracking-tight\" style=\"color: var(--text-primary)\">Sync Queue</h2><button @click=\"clearSyncQueue()\" class=\"btn btn-secondary\">Clear Queue</button></div><div id=\"sync-queue\" class=\"space-y-3\"></div></div></div><div id=\"add-device-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" x-data=\"{ selectedType: '' }\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add New Device</h2><button @click=\"hideAddDeviceModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -425,7 +499,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</button></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Type</label> <select id=\"device-type\" x-model=\"selectedType\" required class=\"input\"><option value=\"\">Select device type...</option> <option value=\"koreader\">KOReader (Kindle, Kobo, PocketBook)</option> <option value=\"kobo\">Kobo E-Reader</option><!-- <option value=\"web\">Web Browser</option> --><!-- <option value=\"mobile\">Mobile App</option> --></select></div><div x-show=\"selectedType === 'koreader'\" x-cloak class=\"space-y-4\"><div class=\"rounded-lg p-4 space-y-4\" style=\"background-color: var(--surface-secondary);\"><h3 class=\"text-sm font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</button></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Type</label> <select id=\"device-type\" x-model=\"selectedType\" required class=\"input\"><option value=\"\">Select device type...</option> <option value=\"koreader\">KOReader (Kindle, Kobo, PocketBook)</option> <option value=\"kobo\">Kobo E-Reader</option><!-- <option value=\"web\">Web Browser</option> --><!-- <option value=\"mobile\">Mobile App</option> --></select></div><div x-show=\"selectedType === 'koreader'\" x-cloak class=\"space-y-4\"><div class=\"rounded-lg p-4 space-y-4\" style=\"background-color: var(--surface-secondary);\"><h3 class=\"text-sm font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -433,33 +507,33 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "KOReader Plugin Setup</h3><ol class=\"space-y-3 text-sm\" style=\"color: var(--text-secondary)\"><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">1</span> <span>Clone the <a href=\"https://git.linuxhg.com/Bookhoard/bookhoard.koplugin\" class=\"underline\" style=\"color: var(--accent);\">Bookhoard plugin</a> to your KOReader <code class=\"px-1 py-0.5 rounded\" style=\"background-color: var(--bg-primary);\">plugins/</code> directory</span></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">2</span> <span>Open KOReader, tap the <strong>wrench icon</strong> at the top</span></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">3</span> <span>Find and tap <strong>Bookhoard sync</strong></span></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">4</span> <span>Tap <strong>Server URL</strong>, enter your server address, then tap <strong>OK</strong>:</span></li><li class=\"ml-8\"><div class=\"flex items-center gap-2\"><code class=\"text-xs px-2 py-1 rounded font-mono flex-1\" style=\"background-color: var(--bg-primary); color: var(--text-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "KOReader Plugin Setup</h3><ol class=\"space-y-3 text-sm\" style=\"color: var(--text-secondary)\"><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">1</span> <span>Clone the <a href=\"https://git.linuxhg.com/Bookhoard/bookhoard.koplugin\" class=\"underline\" style=\"color: var(--accent);\">Bookhoard plugin</a> to your KOReader <code class=\"px-1 py-0.5 rounded\" style=\"background-color: var(--bg-primary);\">plugins/</code> directory</span></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">2</span> <span>Open KOReader, tap the <strong>wrench icon</strong> at the top</span></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">3</span> <span>Find and tap <strong>Bookhoard sync</strong></span></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">4</span> <span>Tap <strong>Server URL</strong>, enter your server address, then tap <strong>OK</strong>:</span></li><li class=\"ml-8\"><div class=\"flex items-center gap-2\"><code class=\"text-xs px-2 py-1 rounded font-mono flex-1\" style=\"background-color: var(--bg-primary); color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
|
||||
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: 242, Col: 148}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 242, Col: 148}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
_, 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, 49, "</code> <button @click=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</code> <button @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')")
|
||||
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: 244, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 244, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
_, 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, 50, "\" class=\"btn btn-secondary text-xs px-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" class=\"btn btn-secondary text-xs px-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -467,7 +541,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</button></div></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">5</span> <span>Return to this page and refresh — you'll see a <strong>pending registration</strong>. Click <strong>Approve</strong> to connect the device.</span></li></ol><div class=\"rounded-md p-3 text-xs flex items-start gap-2\" style=\"background-color: var(--accent-muted); color: var(--text-secondary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</button></div></li><li class=\"flex gap-3\"><span class=\"flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">5</span> <span>Return to this page and refresh — you'll see a <strong>pending registration</strong>. Click <strong>Approve</strong> to connect the device.</span></li></ol><div class=\"rounded-md p-3 text-xs flex items-start gap-2\" style=\"background-color: var(--accent-muted); color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -475,7 +549,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span>Once approved, reading progress sync and OPDS catalog access are set up automatically.</span></div></div><div class=\"flex justify-end\"><button type=\"button\" @click=\"hideAddDeviceModal()\" class=\"btn btn-primary\">Got it</button></div></div><div x-show=\"selectedType === 'kobo'\" x-cloak><form id=\"add-device-form\" @submit=\"handleAddDevice($event)\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Name</label> <input type=\"text\" id=\"device-name\" required class=\"input\" placeholder=\"My Kobo\"></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Identifier</label> <input type=\"text\" id=\"device-identifier\" required class=\"input\" placeholder=\"Hardware ID or Serial Number\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Enter your device's unique identifier</p></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideAddDeviceModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Register Device</button></div></form></div></div></div><div id=\"device-settings-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Device Settings</h2><button @click=\"hideDeviceSettingsModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<span>Once approved, reading progress sync and OPDS catalog access are set up automatically.</span></div></div><div class=\"flex justify-end\"><button type=\"button\" @click=\"hideAddDeviceModal()\" class=\"btn btn-primary\">Got it</button></div></div><div x-show=\"selectedType === 'kobo'\" x-cloak><form id=\"add-device-form\" @submit=\"handleAddDevice($event)\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Name</label> <input type=\"text\" id=\"device-name\" required class=\"input\" placeholder=\"My Kobo\"></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Identifier</label> <input type=\"text\" id=\"device-identifier\" required class=\"input\" placeholder=\"Hardware ID or Serial Number\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Enter your device's unique identifier</p></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideAddDeviceModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Register Device</button></div></form></div></div></div><div id=\"device-settings-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Device Settings</h2><button @click=\"hideDeviceSettingsModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -483,7 +557,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</button></div><form id=\"device-settings-form\" @submit=\"handleSaveDeviceSettings($event)\"><input type=\"hidden\" id=\"settings-device-id\"> <input type=\"hidden\" id=\"settings-device-type\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Name</label> <input type=\"text\" id=\"settings-device-name\" required class=\"input\"></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-sync-enabled\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Enable Sync</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Allow this device to sync reading progress</p></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-auto-sync\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Auto Sync</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Automatically sync changes</p></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sync Frequency</label> <select id=\"settings-sync-frequency\" class=\"input\"><option value=\"1\">Every 1 minute</option> <option value=\"5\" selected>Every 5 minutes</option> <option value=\"15\">Every 15 minutes</option> <option value=\"30\">Every 30 minutes</option> <option value=\"60\">Every hour</option></select></div><div class=\"border-t pt-6 mb-6\" style=\"border-color: var(--border);\"><h3 class=\"text-base font-semibold mb-1\" style=\"color: var(--text-primary)\">Collection View Settings</h3><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Configure how collections are displayed on this device</p><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Default View Mode</label> <select id=\"settings-view-mode\" class=\"input\"><option value=\"grid\">Grid View</option> <option value=\"list\">List View</option> <option value=\"compact\">Compact View</option></select></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sort Collections By</label> <select id=\"settings-sort-order\" class=\"input\"><option value=\"name\">Collection Name</option> <option value=\"created\">Date Created</option> <option value=\"book_count\">Book Count</option> <option value=\"recent\">Recently Added</option></select></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Items Per Page</label> <select id=\"settings-items-per-page\" class=\"input\"><option value=\"12\">12 items</option> <option value=\"24\" selected>24 items</option> <option value=\"48\">48 items</option> <option value=\"96\">96 items</option></select></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-show-covers\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Show Cover Images</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Display book covers in collection views</p></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-show-progress\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Show Reading Progress</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Display progress indicators for books</p></div></div><div class=\"flex justify-end space-x-3 flex-wrap\"><button type=\"button\" @click=\"hideDeviceSettingsModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"handleRevokeDevice()\" class=\"btn btn-danger\">Revoke Device</button> <button type=\"submit\" class=\"btn btn-primary\">Save Settings</button></div></form></div></div><div id=\"shelf-mappings-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Collection to Shelf Mappings</h2><button @click=\"hideShelfMappingsModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</button></div><form id=\"device-settings-form\" @submit=\"handleSaveDeviceSettings($event)\"><input type=\"hidden\" id=\"settings-device-id\"> <input type=\"hidden\" id=\"settings-device-type\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Name</label> <input type=\"text\" id=\"settings-device-name\" required class=\"input\"></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-sync-enabled\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Enable Sync</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Allow this device to sync reading progress</p></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-auto-sync\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Auto Sync</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Automatically sync changes</p></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sync Frequency</label> <select id=\"settings-sync-frequency\" class=\"input\"><option value=\"1\">Every 1 minute</option> <option value=\"5\" selected>Every 5 minutes</option> <option value=\"15\">Every 15 minutes</option> <option value=\"30\">Every 30 minutes</option> <option value=\"60\">Every hour</option></select></div><div class=\"border-t pt-6 mb-6\" style=\"border-color: var(--border);\"><h3 class=\"text-base font-semibold mb-1\" style=\"color: var(--text-primary)\">Collection View Settings</h3><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Configure how collections are displayed on this device</p><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Default View Mode</label> <select id=\"settings-view-mode\" class=\"input\"><option value=\"grid\">Grid View</option> <option value=\"list\">List View</option> <option value=\"compact\">Compact View</option></select></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sort Collections By</label> <select id=\"settings-sort-order\" class=\"input\"><option value=\"name\">Collection Name</option> <option value=\"created\">Date Created</option> <option value=\"book_count\">Book Count</option> <option value=\"recent\">Recently Added</option></select></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Items Per Page</label> <select id=\"settings-items-per-page\" class=\"input\"><option value=\"12\">12 items</option> <option value=\"24\" selected>24 items</option> <option value=\"48\">48 items</option> <option value=\"96\">96 items</option></select></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-show-covers\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Show Cover Images</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Display book covers in collection views</p></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-show-progress\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Show Reading Progress</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Display progress indicators for books</p></div></div><div class=\"flex justify-end space-x-3 flex-wrap\"><button type=\"button\" @click=\"hideDeviceSettingsModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"handleRevokeDevice()\" class=\"btn btn-danger\">Revoke Device</button> <button type=\"submit\" class=\"btn btn-primary\">Save Settings</button></div></form></div></div><div id=\"shelf-mappings-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Collection to Shelf Mappings</h2><button @click=\"hideShelfMappingsModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -491,7 +565,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</button></div><input type=\"hidden\" id=\"mappings-device-id\"><div id=\"shelf-mappings-container\" class=\"space-y-4 mb-6\"><p style=\"color: var(--text-secondary)\">Loading mappings...</p></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideShelfMappingsModal()\" class=\"btn btn-secondary\">Close</button> <button type=\"button\" @click=\"showAddMappingModal()\" class=\"btn btn-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</button></div><input type=\"hidden\" id=\"mappings-device-id\"><div id=\"shelf-mappings-container\" class=\"space-y-4 mb-6\"><p style=\"color: var(--text-secondary)\">Loading mappings...</p></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideShelfMappingsModal()\" class=\"btn btn-secondary\">Close</button> <button type=\"button\" @click=\"showAddMappingModal()\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -499,7 +573,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "Add Mapping</button></div></div></div><div id=\"add-mapping-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Collection Mapping</h2><button @click=\"hideAddMappingModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "Add Mapping</button></div></div></div><div id=\"add-mapping-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Collection Mapping</h2><button @click=\"hideAddMappingModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -507,7 +581,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</button></div><form id=\"mapping-form\" @submit=\"handleSaveMapping($event)\"><input type=\"hidden\" id=\"mapping-device-id\"> <input type=\"hidden\" id=\"mapping-id\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Collection</label> <select id=\"mapping-collection\" required class=\"input\"><option value=\"\">Select collection...</option></select></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Shelf Name</label> <input type=\"text\" id=\"mapping-shelf-name\" required class=\"input\" placeholder=\"e.g., Sci-Fi\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Name of the shelf on this device</p></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sync Direction</label> <select id=\"mapping-sync-direction\" required class=\"input\"><option value=\"bidirectional\">Bidirectional (sync both ways)</option> <option value=\"book_to_device\">Bookhoard → Device</option> <option value=\"device_to_book\">Device → Bookhoard</option> <option value=\"none\">No sync</option></select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideAddMappingModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save Mapping</button></div></form></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</button></div><form id=\"mapping-form\" @submit=\"handleSaveMapping($event)\"><input type=\"hidden\" id=\"mapping-device-id\"> <input type=\"hidden\" id=\"mapping-id\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Collection</label> <select id=\"mapping-collection\" required class=\"input\"><option value=\"\">Select collection...</option></select></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Shelf Name</label> <input type=\"text\" id=\"mapping-shelf-name\" required class=\"input\" placeholder=\"e.g., Sci-Fi\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Name of the shelf on this device</p></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sync Direction</label> <select id=\"mapping-sync-direction\" required class=\"input\"><option value=\"bidirectional\">Bidirectional (sync both ways)</option> <option value=\"book_to_device\">Bookhoard → Device</option> <option value=\"device_to_book\">Device → Bookhoard</option> <option value=\"none\">No sync</option></select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideAddMappingModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save Mapping</button></div></form></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -515,7 +589,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+15
-15
@@ -38,7 +38,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 11, Col: 21}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 11, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -60,7 +60,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -102,7 +102,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 50, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 50, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -133,7 +133,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var6 templ.SafeURL
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 29}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 56, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -146,7 +146,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 57, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 57, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -159,7 +159,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 58, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 58, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -187,7 +187,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var9 templ.SafeURL
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 65, Col: 29}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 65, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -200,7 +200,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 66, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 66, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -213,7 +213,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 67, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 67, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -257,7 +257,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var12 templ.SafeURL
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 82, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -270,7 +270,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 129}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 82, Col: 129}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -293,7 +293,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 104}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 86, Col: 104}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -324,7 +324,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var15 templ.SafeURL
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 96, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -337,7 +337,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 98, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -350,7 +350,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 100, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -36,7 +36,7 @@ func ErrorPage(message string, errorType string) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 84, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `error.templ`, Line: 84, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -49,7 +49,7 @@ func ErrorPage(message string, errorType string) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 90, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `error.templ`, Line: 90, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -36,7 +36,7 @@ func FilterItem(id string, name string) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 6, Col: 21}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `filter_item.templ`, Line: 6, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -49,7 +49,7 @@ func FilterItem(id string, name string) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 14, Col: 9}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `filter_item.templ`, Line: 14, Col: 9}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
+43
-15
@@ -84,7 +84,7 @@ templ Header(user User, currentPath string) {
|
||||
</nav>
|
||||
<!-- Bottom: theme picker + user -->
|
||||
<div
|
||||
x-data="{ openPanel: window.location.pathname.startsWith('/admin') ? 'admin' : null }"
|
||||
x-data={ "{ openPanel: window.location.pathname.startsWith('/admin') ? 'admin' : null, themeMode: '" + string(ThemeModeOf(user.Theme)) + "' }" }
|
||||
class="shrink-0 px-3 py-3 space-y-2 border-t"
|
||||
style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"
|
||||
>
|
||||
@@ -97,7 +97,7 @@ templ Header(user User, currentPath string) {
|
||||
<div class="sidebar-panel">
|
||||
<button
|
||||
type="button"
|
||||
@click="openPanel = openPanel === 'appearance' ? null : 'appearance'"
|
||||
@click={ "openPanel = openPanel === 'appearance' ? null : 'appearance'; if (openPanel === 'appearance') $nextTick(() => revealCurrentTheme(themeMode))" }
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover"
|
||||
>
|
||||
@Icon("palette", "h-5 w-5 shrink-0")
|
||||
@@ -106,22 +106,50 @@ templ Header(user User, currentPath string) {
|
||||
@Icon("chevron-down", "h-4 w-4")
|
||||
</span>
|
||||
</button>
|
||||
<div x-show="openPanel === 'appearance'" x-cloak x-transition class="mt-1 space-y-0.5 pl-1">
|
||||
for _, opt := range ThemeOptions {
|
||||
<div x-show="openPanel === 'appearance'" x-cloak x-transition class="mt-1 pl-1">
|
||||
<!-- Dark / Light filter -->
|
||||
<div class="grid grid-cols-2 gap-1 px-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
@click={ "changeTheme('" + opt.Name + "'); openPanel = null" }
|
||||
class="theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover"
|
||||
data-theme={ opt.Name }
|
||||
style="color: var(--text-secondary);"
|
||||
@click="themeMode = 'dark'; $nextTick(() => revealCurrentTheme('dark'))"
|
||||
class="flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class={ "themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'" }
|
||||
>
|
||||
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style={ "background-color: " + opt.Color }></span>
|
||||
<span class="flex-1 text-left">{ opt.Label }</span>
|
||||
<span class={ "theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme) }>
|
||||
@Icon("check", "h-4 w-4")
|
||||
</span>
|
||||
@Icon("moon", "h-3.5 w-3.5 shrink-0")
|
||||
<span>Dark</span>
|
||||
<span class="tabular-nums opacity-50">{ len(ThemeOptionsFor(ModeDark)) }</span>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
@click="themeMode = 'light'; $nextTick(() => revealCurrentTheme('light'))"
|
||||
class="flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class={ "themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'" }
|
||||
>
|
||||
@Icon("sun", "h-3.5 w-3.5 shrink-0")
|
||||
<span>Light</span>
|
||||
<span class="tabular-nums opacity-50">{ len(ThemeOptionsFor(ModeLight)) }</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Scrollable theme list (filtered by the mode above) -->
|
||||
<div class="mt-1 max-h-72 overflow-y-auto pr-1 space-y-0.5">
|
||||
for _, opt := range ThemeOptions {
|
||||
<button
|
||||
type="button"
|
||||
@click={ "changeTheme('" + opt.Name + "'); openPanel = null" }
|
||||
class="theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover"
|
||||
x-show={ "themeMode === '" + string(opt.Mode) + "'" }
|
||||
data-theme={ opt.Name }
|
||||
data-mode={ string(opt.Mode) }
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style={ "background-color: " + opt.Color }></span>
|
||||
<span class="flex-1 text-left">{ opt.Label }</span>
|
||||
<span class={ "theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme) }>
|
||||
@Icon("check", "h-4 w-4")
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="my-1.5 mx-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"></div>
|
||||
<p class="px-3 pb-1 text-xs font-medium uppercase tracking-wide" style="color: var(--text-secondary);">Bookshelf</p>
|
||||
for _, w := range WoodOptions {
|
||||
@@ -213,7 +241,7 @@ templ Header(user User, currentPath string) {
|
||||
</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=5"></script>
|
||||
<script type="module" src="/static/main.js?v=6"></script>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
+263
-143
@@ -53,7 +53,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -83,7 +83,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -113,7 +113,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -143,7 +143,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -173,7 +173,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -203,7 +203,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var12).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -233,7 +233,7 @@ func Header(user User, currentPath string) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var14).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -247,7 +247,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, 23, "<span>Devices</span></a></nav><!-- Bottom: theme picker + user --><div x-data=\"{ openPanel: window.location.pathname.startsWith('/admin') ? 'admin' : null }\" class=\"shrink-0 px-3 py-3 space-y-2 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span>Devices</span></a></nav><!-- Bottom: theme picker + user --><div x-data=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("{ openPanel: window.location.pathname.startsWith('/admin') ? 'admin' : null, themeMode: '" + string(ThemeModeOf(user.Theme)) + "' }")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 87, Col: 146}
|
||||
}
|
||||
_, 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, 24, "\" class=\"shrink-0 px-3 py-3 space-y-2 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -262,7 +275,20 @@ func Header(user User, currentPath string) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<!-- Theme picker --><div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'appearance' ? null : 'appearance'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<!-- Theme picker --><div class=\"sidebar-panel\"><button type=\"button\" @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("openPanel = openPanel === 'appearance' ? null : 'appearance'; if (openPanel === 'appearance') $nextTick(() => revealCurrentTheme(themeMode))")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 100, Col: 157}
|
||||
}
|
||||
_, 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, 26, "\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -270,7 +296,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, 25, "<span>Appearance</span> <span class=\"ml-auto h-4 w-4 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'appearance' }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span>Appearance</span> <span class=\"ml-auto h-4 w-4 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'appearance' }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -278,86 +304,180 @@ 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, 26, "</span></button><div x-show=\"openPanel === 'appearance'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span></button><div x-show=\"openPanel === 'appearance'\" x-cloak x-transition class=\"mt-1 pl-1\"><!-- Dark / Light filter --><div class=\"grid grid-cols-2 gap-1 px-2 pt-1\"><button type=\"button\" @click=\"themeMode = 'dark'; $nextTick(() => revealCurrentTheme('dark'))\" class=\"flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors\" :class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, 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: `header.templ`, Line: 116, Col: 104}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("moon", "h-3.5 w-3.5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span>Dark</span> <span class=\"tabular-nums opacity-50\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeDark)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 120, Col: 78}
|
||||
}
|
||||
_, 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, 31, "</span></button> <button type=\"button\" @click=\"themeMode = 'light'; $nextTick(() => revealCurrentTheme('light'))\" class=\"flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors\" :class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, 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: `header.templ`, Line: 126, Col: 105}
|
||||
}
|
||||
_, 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, 32, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("sun", "h-3.5 w-3.5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span>Light</span> <span class=\"tabular-nums opacity-50\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(len(ThemeOptionsFor(ModeLight)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 130, Col: 79}
|
||||
}
|
||||
_, 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, 34, "</span></button></div><!-- Scrollable theme list (filtered by the mode above) --><div class=\"mt-1 max-h-72 overflow-y-auto pr-1 space-y-0.5\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, opt := range ThemeOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button type=\"button\" @click=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<button type=\"button\" @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeTheme('" + opt.Name + "'); openPanel = null")
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeTheme('" + opt.Name + "'); openPanel = null")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 113, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 138, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
_, 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, 28, "\" class=\"theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" data-theme=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" class=\"theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" x-show=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, 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: 115, Col: 29}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 140, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
_, 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, 29, "\" style=\"color: var(--text-secondary);\"><span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" data-theme=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 118, Col: 130}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 141, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
_, 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, 30, "\"></span> <span class=\"flex-1 text-left\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" data-mode=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, 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: 119, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 142, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
_, 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, 31, "</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" style=\"color: var(--text-secondary);\"><span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 = []any{"theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 145, Col: 131}
|
||||
}
|
||||
_, 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, 32, "<span class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\"></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.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var20).String())
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 146, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
_, 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, 33, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 = []any{"theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var28).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, 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, 43, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -365,94 +485,94 @@ 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, 34, "</span></button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</span></button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"my-1.5 mx-3 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"></div><p class=\"px-3 pb-1 text-xs font-medium uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Bookshelf</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div><div class=\"my-1.5 mx-3 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"></div><p class=\"px-3 pb-1 text-xs font-medium uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Bookshelf</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, w := range WoodOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<button type=\"button\" @click=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<button type=\"button\" @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, 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: 130, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 158, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
_, 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, 37, "\" class=\"wood-paneling-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors\" data-wood=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" class=\"wood-paneling-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors\" data-wood=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 132, Col: 26}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 160, Col: 26}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
_, 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, 38, "\" style=\"color: var(--text-secondary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if w.Name == "none" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);\"></span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);\"></span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, 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: 138, Col: 191}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 166, Col: 191}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
_, 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, 41, "\"></span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"></span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span class=\"flex-1 text-left\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span class=\"flex-1 text-left\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 140, Col: 48}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 168, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
_, 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, 43, "</span></button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span></button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></div><!-- Admin section (visible to admins only) -->")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</div></div><!-- Admin section (visible to admins only) -->")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'admin' ? null : 'admin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'admin' ? null : 'admin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -460,29 +580,29 @@ 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, 46, "<span>Administration</span> <svg class=\"h-4 w-4 ml-auto transition-transform\" :class=\"{ 'rotate-180': openPanel === 'admin' }\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M19 9l-7 7-7-7\"></path></svg></button><div x-show=\"openPanel === 'admin'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span>Administration</span> <svg class=\"h-4 w-4 ml-auto transition-transform\" :class=\"{ 'rotate-180': openPanel === 'admin' }\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M19 9l-7 7-7-7\"></path></svg></button><div x-show=\"openPanel === 'admin'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 = []any{activeClass(currentPath, "/admin")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...)
|
||||
var templ_7745c5c3_Var34 = []any{activeClass(currentPath, "/admin")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<a href=\"/admin\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<a href=\"/admin\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var26).String())
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var34).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
_, 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, 48, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -490,29 +610,29 @@ 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, 49, "<span>Dashboard</span></a> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<span>Dashboard</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 = []any{activeClass(currentPath, "/admin/library")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...)
|
||||
var templ_7745c5c3_Var36 = []any{activeClass(currentPath, "/admin/library")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var36...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<a href=\"/admin/library\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<a href=\"/admin/library\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var28).String())
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var36).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||
_, 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, 51, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -520,29 +640,29 @@ 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, 52, "<span>Libraries</span></a> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<span>Libraries</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 = []any{activeClass(currentPath, "/admin/hash-conflicts")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...)
|
||||
var templ_7745c5c3_Var38 = []any{activeClass(currentPath, "/admin/hash-conflicts")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<a href=\"/admin/hash-conflicts\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<a href=\"/admin/hash-conflicts\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var30).String())
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var38).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||
_, 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, 54, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -550,29 +670,29 @@ 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, 55, "<span>Hash Conflicts</span></a> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<span>Hash Conflicts</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var32 = []any{activeClass(currentPath, "/admin/users")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...)
|
||||
var templ_7745c5c3_Var40 = []any{activeClass(currentPath, "/admin/users")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var40...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<a href=\"/admin/users\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a href=\"/admin/users\" 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_Var32).String())
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var40).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -580,29 +700,29 @@ 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, 58, "<span>Users</span></a> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<span>Users</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var34 = []any{activeClass(currentPath, "/admin/settings")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...)
|
||||
var templ_7745c5c3_Var42 = []any{activeClass(currentPath, "/admin/settings")}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<a href=\"/admin/settings\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<a href=\"/admin/settings\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var34).String())
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var42).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||
_, 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, 60, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -610,12 +730,12 @@ 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, 61, "<span>Settings</span></a></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<span>Settings</span></a></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</div></aside><!-- Topbar --><header class=\"app-topbar\"><div class=\"flex items-center gap-3 px-4 sm:px-6 h-full\"><button type=\"button\" class=\"app-mobile-only icon-btn\" @click=\"mobileMenuOpen = true\" aria-label=\"Open menu\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</div></aside><!-- Topbar --><header class=\"app-topbar\"><div class=\"flex items-center gap-3 px-4 sm:px-6 h-full\"><button type=\"button\" class=\"app-mobile-only icon-btn\" @click=\"mobileMenuOpen = true\" aria-label=\"Open menu\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -623,7 +743,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, 63, "</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, 73, "</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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -631,7 +751,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, 64, "</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=5\"></script></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</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=6\"></script></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -656,12 +776,12 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var36 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var36 == nil {
|
||||
templ_7745c5c3_Var36 = templ.NopComponent
|
||||
templ_7745c5c3_Var44 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var44 == nil {
|
||||
templ_7745c5c3_Var44 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<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, 75, "<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
|
||||
}
|
||||
@@ -669,20 +789,20 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span> <span class=\"flex-1 text-left truncate\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span> <span class=\"flex-1 text-left truncate\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 232, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 260, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</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
|
||||
}
|
||||
@@ -690,7 +810,7 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</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, 78, "</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
|
||||
}
|
||||
@@ -698,7 +818,7 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<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, 79, "<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
|
||||
}
|
||||
@@ -706,7 +826,7 @@ func SidebarUserMenu(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<span>Logout</span></button></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span>Logout</span></button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -732,12 +852,12 @@ func SidebarSignIn(currentPath string) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var38 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var38 == nil {
|
||||
templ_7745c5c3_Var38 = templ.NopComponent
|
||||
templ_7745c5c3_Var46 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var46 == nil {
|
||||
templ_7745c5c3_Var46 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<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, 81, "<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
|
||||
}
|
||||
@@ -745,7 +865,7 @@ func SidebarSignIn(currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</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, 82, "</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
|
||||
}
|
||||
@@ -753,30 +873,30 @@ func SidebarSignIn(currentPath string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</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, 83, "</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_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
var templ_7745c5c3_Var47 string
|
||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 280, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 308, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\"> <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, 84, "\"> <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, 75, "<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, 85, "<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, 76, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ templ Icon(name string, extra string) {
|
||||
<circle cx="15" cy="12" r="1"></circle>
|
||||
<circle cx="9" cy="18" r="1"></circle>
|
||||
<circle cx="15" cy="18" r="1"></circle>
|
||||
case "moon":
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
|
||||
case "sun":
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"></path>
|
||||
case "settings":
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"></path>
|
||||
|
||||
+58
-48
@@ -45,7 +45,7 @@ func Icon(name string, extra string) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/icons.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `icons.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -121,238 +121,248 @@ func Icon(name string, extra string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "moon":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<path d=\"M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "sun":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<circle cx=\"12\" cy=\"12\" r=\"4\"></circle> <path d=\"M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "settings":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<circle cx=\"12\" cy=\"12\" r=\"3\"></circle> <path d=\"M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<circle cx=\"12\" cy=\"12\" r=\"3\"></circle> <path d=\"M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "gear":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<circle cx=\"12\" cy=\"12\" r=\"3\"></circle> <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<circle cx=\"12\" cy=\"12\" r=\"3\"></circle> <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "refresh":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<path d=\"M3 12a9 9 0 0 1 15-6.7L21 8\"></path> <path d=\"M21 3v5h-5\"></path> <path d=\"M21 12a9 9 0 0 1-15 6.7L3 16\"></path> <path d=\"M3 21v-5h5\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<path d=\"M3 12a9 9 0 0 1 15-6.7L21 8\"></path> <path d=\"M21 3v5h-5\"></path> <path d=\"M21 12a9 9 0 0 1-15 6.7L3 16\"></path> <path d=\"M3 21v-5h5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "book":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<path d=\"M4 19.5A2.5 2.5 0 0 1 6.5 17H20\"></path> <path d=\"M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<path d=\"M4 19.5A2.5 2.5 0 0 1 6.5 17H20\"></path> <path d=\"M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "book-open":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<path d=\"M12 7v14\"></path> <path d=\"M3 4h6a3 3 0 0 1 3 3v14a2 2 0 0 0-2-2H3z\"></path> <path d=\"M21 4h-6a3 3 0 0 0-3 3v14a2 2 0 0 1 2-2h7z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<path d=\"M12 7v14\"></path> <path d=\"M3 4h6a3 3 0 0 1 3 3v14a2 2 0 0 0-2-2H3z\"></path> <path d=\"M21 4h-6a3 3 0 0 0-3 3v14a2 2 0 0 1 2-2h7z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "library":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<path d=\"M3 21h18\"></path> <path d=\"M5 21V7l7-4 7 4v14\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<path d=\"M3 21h18\"></path> <path d=\"M5 21V7l7-4 7 4v14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "layers":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<path d=\"m12 3 9 5-9 5-9-5 9-5z\"></path> <path d=\"m3 13 9 5 9-5\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<path d=\"m12 3 9 5-9 5-9-5 9-5z\"></path> <path d=\"m3 13 9 5 9-5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "folder":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<path d=\"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<path d=\"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chart":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<path d=\"M3 3v18h18\"></path> <path d=\"M7 15v3M12 11v7M17 7v11\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<path d=\"M3 3v18h18\"></path> <path d=\"M7 15v3M12 11v7M17 7v11\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "device":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<rect x=\"7\" y=\"2\" width=\"10\" height=\"20\" rx=\"2\"></rect> <path d=\"M11 18h2\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<rect x=\"7\" y=\"2\" width=\"10\" height=\"20\" rx=\"2\"></rect> <path d=\"M11 18h2\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "trash":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<path d=\"M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<path d=\"M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "save":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<path d=\"M5 3h11l3 3v15H5z\"></path> <path d=\"M8 3v6h7V3\"></path> <path d=\"M8 14h8v7H8z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<path d=\"M5 3h11l3 3v15H5z\"></path> <path d=\"M8 3v6h7V3\"></path> <path d=\"M8 14h8v7H8z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "filter":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<path d=\"M3 4h18l-7 8v6l-4 2v-8z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<path d=\"M3 4h18l-7 8v6l-4 2v-8z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "check":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<path d=\"m4 12 5 5 11-11\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<path d=\"m4 12 5 5 11-11\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "check-circle":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m8 12 3 3 5-6\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m8 12 3 3 5-6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "x-circle":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m9 9 6 6M15 9l-6 6\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m9 9 6 6M15 9l-6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "circle":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<circle cx=\"12\" cy=\"12\" r=\"8\"></circle>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<circle cx=\"12\" cy=\"12\" r=\"8\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "bookmark":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<path d=\"M7 3h10a1 1 0 0 1 1 1v17l-6-4-6 4V4a1 1 0 0 1 1-1z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<path d=\"M7 3h10a1 1 0 0 1 1 1v17l-6-4-6 4V4a1 1 0 0 1 1-1z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "star":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<path d=\"m12 3 2.7 5.5 6 .9-4.3 4.2 1 6L12 17l-5.4 2.6 1-6L3.3 9.4l6-.9z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<path d=\"m12 3 2.7 5.5 6 .9-4.3 4.2 1 6L12 17l-5.4 2.6 1-6L3.3 9.4l6-.9z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "arrow-right":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<path d=\"M5 12h14M13 6l6 6-6 6\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<path d=\"M5 12h14M13 6l6 6-6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "arrow-left":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<path d=\"M19 12H5M11 6l-6 6 6 6\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<path d=\"M19 12H5M11 6l-6 6 6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "home":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<path d=\"M3 11 12 3l9 8\"></path> <path d=\"M5 10v10h14V10\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<path d=\"M3 11 12 3l9 8\"></path> <path d=\"M5 10v10h14V10\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "logout":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<path d=\"M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3\"></path> <path d=\"M10 17l5-5-5-5\"></path> <path d=\"M15 12H3\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<path d=\"M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3\"></path> <path d=\"M10 17l5-5-5-5\"></path> <path d=\"M15 12H3\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "plus":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<path d=\"M12 5v14M5 12h14\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<path d=\"M12 5v14M5 12h14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "edit":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<path d=\"M4 20h4L18 10l-4-4L4 16z\"></path> <path d=\"m14 6 4 4\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<path d=\"M4 20h4L18 10l-4-4L4 16z\"></path> <path d=\"m14 6 4 4\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "info":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 11v5M12 8h.01\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 11v5M12 8h.01\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "alert":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<path d=\"M12 3 2 20h20z\"></path> <path d=\"M12 10v4M12 17h.01\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<path d=\"M12 3 2 20h20z\"></path> <path d=\"M12 10v4M12 17h.01\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "clock":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 7v5l3 2\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 7v5l3 2\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "sync":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<path d=\"M3 8a9 9 0 0 1 15-3l3 3\"></path> <path d=\"M21 16a9 9 0 0 1-15 3l-3-3\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<path d=\"M3 8a9 9 0 0 1 15-3l3 3\"></path> <path d=\"M21 16a9 9 0 0 1-15 3l-3-3\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "download":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<path d=\"M12 3v12M7 10l5 5 5-5\"></path> <path d=\"M5 21h14\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<path d=\"M12 3v12M7 10l5 5 5-5\"></path> <path d=\"M5 21h14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "upload":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<path d=\"M12 21V9M7 14l5-5 5 5\"></path> <path d=\"M5 3h14\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<path d=\"M12 21V9M7 14l5-5 5 5\"></path> <path d=\"M5 3h14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "copy":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<rect x=\"9\" y=\"9\" width=\"12\" height=\"12\" rx=\"2\"></rect> <path d=\"M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<rect x=\"9\" y=\"9\" width=\"12\" height=\"12\" rx=\"2\"></rect> <path d=\"M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "external":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<path d=\"M14 4h6v6\"></path> <path d=\"M20 4 10 14\"></path> <path d=\"M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<path d=\"M14 4h6v6\"></path> <path d=\"M20 4 10 14\"></path> <path d=\"M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "list":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "grid":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "play":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<path d=\"M6 4l14 8-14 8z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<path d=\"M6 4l14 8-14 8z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "heart":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<path d=\"M12 21s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<path d=\"M12 21s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "calendar":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\"></rect> <path d=\"M3 9h18M8 2v4M16 2v4\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\"></rect> <path d=\"M3 9h18M8 2v4M16 2v4\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "tag":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<path d=\"M3 11V5a2 2 0 0 1 2-2h6l9 9-8 8-9-9z\"></path> <circle cx=\"7.5\" cy=\"7.5\" r=\"1\"></circle>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<path d=\"M3 11V5a2 2 0 0 1 2-2h6l9 9-8 8-9-9z\"></path> <circle cx=\"7.5\" cy=\"7.5\" r=\"1\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "globe":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "shield":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<path d=\"M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<path d=\"M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "database":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<ellipse cx=\"12\" cy=\"5\" rx=\"8\" ry=\"3\"></ellipse> <path d=\"M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5\"></path> <path d=\"M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<ellipse cx=\"12\" cy=\"5\" rx=\"8\" ry=\"3\"></ellipse> <path d=\"M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5\"></path> <path d=\"M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "headphones":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<path d=\"M4 14v-2a8 8 0 0 1 16 0v2\"></path> <rect x=\"3\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect> <rect x=\"17\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<path d=\"M4 14v-2a8 8 0 0 1 16 0v2\"></path> <rect x=\"3\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect> <rect x=\"17\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "comic":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<path d=\"M4 4h16v12l-3 4H7l-3-4z\"></path> <path d=\"M4 9h16M9 9v11\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<path d=\"M4 4h16v12l-3 4H7l-3-4z\"></path> <path d=\"M4 9h16M9 9v11\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "manga":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<path d=\"M4 4h16v13a3 3 0 0 1-3 3H4z\"></path> <path d=\"M4 4v16M10 4h10v8\"></path>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<path d=\"M4 4h16v13a3 3 0 0 1-3 3H4z\"></path> <path d=\"M4 4v16M10 4h10v8\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
default:
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</svg>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+11
-3
@@ -4,6 +4,7 @@ templ Index(loggedIn bool, registrationEnabled bool) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ThemeFlashGuard()
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Bookhoard - Home</title>
|
||||
@@ -20,9 +21,16 @@ templ Index(loggedIn bool, registrationEnabled bool) {
|
||||
class="input w-auto py-1.5 pr-8 text-sm cursor-pointer"
|
||||
aria-label="Theme"
|
||||
>
|
||||
for _, opt := range ThemeOptions {
|
||||
<option value={ opt.Name }>{ opt.Label }</option>
|
||||
}
|
||||
<optgroup label="Dark">
|
||||
for _, opt := range ThemeOptionsFor(ModeDark) {
|
||||
<option value={ opt.Name }>{ opt.Label }</option>
|
||||
}
|
||||
</optgroup>
|
||||
<optgroup label="Light">
|
||||
for _, opt := range ThemeOptionsFor(ModeLight) {
|
||||
<option value={ opt.Name }>{ opt.Label }</option>
|
||||
}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Hero -->
|
||||
|
||||
+62
-18
@@ -29,58 +29,102 @@ func Index(loggedIn bool, registrationEnabled bool) templ.Component {
|
||||
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>Bookhoard - Home</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-tokyo-night\" x-data=\"index\" x-init=\"initIndexTheme(); checkAuthRedirect()\"><div class=\"absolute top-4 right-4 z-10\"><select id=\"theme-select\" @change=\"changeTheme\" class=\"input w-auto py-1.5 pr-8 text-sm cursor-pointer\" aria-label=\"Theme\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, opt := range ThemeOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"")
|
||||
templ_7745c5c3_Err = ThemeFlashGuard().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Bookhoard - Home</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-tokyo-night\" x-data=\"index\" x-init=\"initIndexTheme(); checkAuthRedirect()\"><div class=\"absolute top-4 right-4 z-10\"><select id=\"theme-select\" @change=\"changeTheme\" class=\"input w-auto py-1.5 pr-8 text-sm cursor-pointer\" 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, 3, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 26, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 26, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</select></div><!-- Hero --><div class=\"brand-gradient relative overflow-hidden\"><div class=\"max-w-7xl mx-auto px-4 py-20 sm:px-6 sm:py-28 lg:py-36 lg:px-8 text-center\"><div class=\"inline-flex items-center gap-3 mb-6\"><span class=\"text-5xl\">📚</span> <span class=\"text-4xl sm:text-6xl font-extrabold tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span></div><p class=\"mt-4 max-w-2xl mx-auto text-lg sm:text-xl\" style=\"color: var(--text-secondary)\">Your personal ebook management system. Track reading progress, organize your library, and sync across every device.</p><div class=\"mt-10 flex items-center justify-center gap-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</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, 7, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 31, Col: 31}
|
||||
}
|
||||
_, 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, 8, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 31, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</optgroup></select></div><!-- Hero --><div class=\"brand-gradient relative overflow-hidden\"><div class=\"max-w-7xl mx-auto px-4 py-20 sm:px-6 sm:py-28 lg:py-36 lg:px-8 text-center\"><div class=\"inline-flex items-center gap-3 mb-6\"><span class=\"text-5xl\">📚</span> <span class=\"text-4xl sm:text-6xl font-extrabold tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span></div><p class=\"mt-4 max-w-2xl mx-auto text-lg sm:text-xl\" style=\"color: var(--text-secondary)\">Your personal ebook management system. Track reading progress, organize your library, and sync across every device.</p><div class=\"mt-10 flex items-center justify-center gap-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if loggedIn {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"/dashboard\" class=\"btn btn-primary px-8 py-3 text-base\">Go to Library</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<a href=\"/dashboard\" class=\"btn btn-primary px-8 py-3 text-base\">Go to Library</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"#auth\" class=\"btn btn-primary px-8 py-3 text-base\">Get Started</a> <a href=\"#features\" class=\"btn btn-secondary px-6 py-3 text-base\">Learn more</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"#auth\" class=\"btn btn-primary px-8 py-3 text-base\">Get Started</a> <a href=\"#features\" class=\"btn btn-secondary px-6 py-3 text-base\">Learn more</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></div></div><!-- Features --><div id=\"features\" class=\"py-16 sm:py-20\"><div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\"><div class=\"text-center mb-12\"><h2 class=\"text-3xl font-extrabold tracking-tight\" style=\"color: var(--text-primary)\">Why Bookhoard?</h2><p class=\"mt-3 text-lg\" style=\"color: var(--text-secondary)\">Everything you need to manage your ebook library</p></div><div class=\"grid grid-cols-1 md:grid-cols-3 gap-6\"><div class=\"card p-6\"><div class=\"grid place-items-center h-12 w-12 rounded-xl mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></div></div><!-- Features --><div id=\"features\" class=\"py-16 sm:py-20\"><div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\"><div class=\"text-center mb-12\"><h2 class=\"text-3xl font-extrabold tracking-tight\" style=\"color: var(--text-primary)\">Why Bookhoard?</h2><p class=\"mt-3 text-lg\" style=\"color: var(--text-secondary)\">Everything you need to manage your ebook library</p></div><div class=\"grid grid-cols-1 md:grid-cols-3 gap-6\"><div class=\"card p-6\"><div class=\"grid place-items-center h-12 w-12 rounded-xl mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -88,7 +132,7 @@ func Index(loggedIn bool, registrationEnabled bool) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Reading Progress</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Track progress across all your ebooks with detailed statistics and sync.</p></div><div class=\"card p-6\"><div class=\"grid place-items-center h-12 w-12 rounded-xl mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Reading Progress</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Track progress across all your ebooks with detailed statistics and sync.</p></div><div class=\"card p-6\"><div class=\"grid place-items-center h-12 w-12 rounded-xl mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -96,7 +140,7 @@ func Index(loggedIn bool, registrationEnabled bool) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Beautiful Themes</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin.</p></div><div class=\"card p-6\"><div class=\"grid place-items-center h-12 w-12 rounded-xl mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Beautiful Themes</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin.</p></div><div class=\"card p-6\"><div class=\"grid place-items-center h-12 w-12 rounded-xl mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -104,27 +148,27 @@ func Index(loggedIn bool, registrationEnabled bool) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Cross-Device Sync</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Built with Go and HTMX for fast performance and seamless device syncing.</p></div></div></div></div><!-- Auth -->")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Cross-Device Sync</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Built with Go and HTMX for fast performance and seamless device syncing.</p></div></div></div></div><!-- Auth -->")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !loggedIn {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div id=\"auth\" class=\"pb-20 px-4\"><div class=\"max-w-md mx-auto\"><div class=\"text-center mb-8\"><h2 class=\"text-2xl font-extrabold tracking-tight\" style=\"color: var(--text-primary)\">Welcome back</h2><p class=\"mt-2\" style=\"color: var(--text-secondary)\">Sign in to start managing your ebook library</p></div><div class=\"card p-8\"><form hx-post=\"/api/auth/login\" hx-target=\"#auth-result\" hx-swap=\"innerHTML\" class=\"space-y-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"input\" required></div><button type=\"submit\" class=\"btn btn-primary w-full py-2.5\">Sign In</button></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div id=\"auth\" class=\"pb-20 px-4\"><div class=\"max-w-md mx-auto\"><div class=\"text-center mb-8\"><h2 class=\"text-2xl font-extrabold tracking-tight\" style=\"color: var(--text-primary)\">Welcome back</h2><p class=\"mt-2\" style=\"color: var(--text-secondary)\">Sign in to start managing your ebook library</p></div><div class=\"card p-8\"><form hx-post=\"/api/auth/login\" hx-target=\"#auth-result\" hx-swap=\"innerHTML\" class=\"space-y-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"input\" required></div><button type=\"submit\" class=\"btn btn-primary w-full py-2.5\">Sign In</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if registrationEnabled {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"text-center text-sm mt-4\" style=\"color: var(--text-secondary)\">No account? <a href=\"/register\" class=\"font-medium hover:underline\" style=\"color: var(--accent)\">Register</a></p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<p class=\"text-center text-sm mt-4\" style=\"color: var(--text-secondary)\">No account? <a href=\"/register\" class=\"font-medium hover:underline\" style=\"color: var(--accent)\">Register</a></p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div id=\"auth-result\" class=\"mt-4 text-center text-sm\"></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div id=\"auth-result\" class=\"mt-4 text-center text-sm\"></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, title stri
|
||||
</div>
|
||||
<div
|
||||
id="loading-spinner"
|
||||
class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
|
||||
style="background-color: var(--bg-primary);"
|
||||
class="hidden fixed inset-0 flex items-center justify-center z-50"
|
||||
style="background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
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: `library_switcher.templ`, Line: 11, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -64,7 +64,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: `library_switcher.templ`, Line: 20, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -84,7 +84,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: `library_switcher.templ`, Line: 24, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -97,7 +97,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: `library_switcher.templ`, Line: 24, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -110,7 +110,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: `library_switcher.templ`, Line: 24, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -128,7 +128,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: `library_switcher.templ`, Line: 26, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -141,7 +141,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: `library_switcher.templ`, Line: 26, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -154,7 +154,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: `library_switcher.templ`, Line: 26, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -173,7 +173,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: `library_switcher.templ`, Line: 29, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -186,7 +186,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: `library_switcher.templ`, Line: 29, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -199,7 +199,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: `library_switcher.templ`, Line: 29, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -217,7 +217,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: `library_switcher.templ`, Line: 31, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -230,7 +230,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: `library_switcher.templ`, Line: 31, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -243,7 +243,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: `library_switcher.templ`, Line: 31, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -275,7 +275,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, title strin
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 flex items-center justify-center z-50\" style=\"background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+11
-3
@@ -4,6 +4,7 @@ templ Login(sessionExpired bool, deleted bool, registrationEnabled bool) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ThemeFlashGuard()
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Login - Bookhoard</title>
|
||||
@@ -20,9 +21,16 @@ templ Login(sessionExpired bool, deleted bool, registrationEnabled bool) {
|
||||
class="input absolute top-4 right-4 w-auto py-1.5 pr-8 text-sm cursor-pointer"
|
||||
aria-label="Theme"
|
||||
>
|
||||
for _, opt := range ThemeOptions {
|
||||
<option value={ opt.Name }>{ opt.Label }</option>
|
||||
}
|
||||
<optgroup label="Dark">
|
||||
for _, opt := range ThemeOptionsFor(ModeDark) {
|
||||
<option value={ opt.Name }>{ opt.Label }</option>
|
||||
}
|
||||
</optgroup>
|
||||
<optgroup label="Light">
|
||||
for _, opt := range ThemeOptionsFor(ModeLight) {
|
||||
<option value={ opt.Name }>{ opt.Label }</option>
|
||||
}
|
||||
</optgroup>
|
||||
</select>
|
||||
<div class="w-full max-w-sm">
|
||||
<a href="/" class="flex items-center justify-center gap-2.5 mb-8">
|
||||
|
||||
+59
-15
@@ -29,48 +29,92 @@ func Login(sessionExpired bool, deleted bool, registrationEnabled bool) templ.Co
|
||||
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>Login - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-tokyo-night min-h-screen\" x-data=\"login\" x-init=\"initLoginTheme()\"><div class=\"brand-gradient min-h-screen flex flex-col items-center justify-center p-4 relative\"><select id=\"theme-select\" @change=\"changeTheme()\" class=\"input absolute top-4 right-4 w-auto py-1.5 pr-8 text-sm cursor-pointer\" aria-label=\"Theme\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, opt := range ThemeOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"")
|
||||
templ_7745c5c3_Err = ThemeFlashGuard().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Login - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-tokyo-night min-h-screen\" x-data=\"login\" x-init=\"initLoginTheme()\"><div class=\"brand-gradient min-h-screen flex flex-col items-center justify-center p-4 relative\"><select id=\"theme-select\" @change=\"changeTheme()\" class=\"input absolute top-4 right-4 w-auto py-1.5 pr-8 text-sm cursor-pointer\" 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, 3, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 24, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `login.templ`, Line: 26, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 24, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `login.templ`, Line: 26, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</select><div class=\"w-full max-w-sm\"><a href=\"/\" class=\"flex items-center justify-center gap-2.5 mb-8\"><span class=\"text-3xl\">📚</span> <span class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span></a> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</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, 7, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `login.templ`, Line: 31, Col: 31}
|
||||
}
|
||||
_, 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, 8, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `login.templ`, Line: 31, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</optgroup></select><div class=\"w-full max-w-sm\"><a href=\"/\" class=\"flex items-center justify-center gap-2.5 mb-8\"><span class=\"text-3xl\">📚</span> <span class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if sessionExpired {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"mb-4 p-3 rounded-xl flex items-start gap-2\" style=\"background-color: color-mix(in srgb, var(--accent) 14%, transparent); border: 1px solid var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"mb-4 p-3 rounded-xl flex items-start gap-2\" style=\"background-color: color-mix(in srgb, var(--accent) 14%, transparent); border: 1px solid var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -78,13 +122,13 @@ func Login(sessionExpired bool, deleted bool, registrationEnabled bool) templ.Co
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<p class=\"text-sm\" style=\"color: var(--text-primary)\">Your session has expired. Please log in again to continue.</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<p class=\"text-sm\" style=\"color: var(--text-primary)\">Your session has expired. Please log in again to continue.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if deleted {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"mb-4 p-3 rounded-xl flex items-start gap-2\" style=\"background-color: color-mix(in srgb, var(--status-success) 14%, transparent); border: 1px solid var(--status-success);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"mb-4 p-3 rounded-xl flex items-start gap-2\" style=\"background-color: color-mix(in srgb, var(--status-success) 14%, transparent); border: 1px solid var(--status-success);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -92,22 +136,22 @@ func Login(sessionExpired bool, deleted bool, registrationEnabled bool) templ.Co
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<p class=\"text-sm\" style=\"color: var(--text-primary)\">Your account has been deleted successfully.</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<p class=\"text-sm\" style=\"color: var(--text-primary)\">Your account has been deleted successfully.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<form hx-post=\"/api/auth/login\" hx-target=\"#result\" hx-swap=\"innerHTML\" class=\"card p-6 space-y-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"input\" required></div><button type=\"submit\" class=\"btn btn-primary w-full py-2.5\">Sign In</button></form><div id=\"result\" class=\"mt-4 text-center text-sm\"></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<form hx-post=\"/api/auth/login\" hx-target=\"#result\" hx-swap=\"innerHTML\" class=\"card p-6 space-y-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"input\" required></div><button type=\"submit\" class=\"btn btn-primary w-full py-2.5\">Sign In</button></form><div id=\"result\" class=\"mt-4 text-center text-sm\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if registrationEnabled {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<p class=\"text-center mt-6 text-sm\" style=\"color: var(--text-secondary)\">Don't have an account? <a href=\"/register\" class=\"font-medium hover:underline\" style=\"color: var(--accent)\">Sign up</a></p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<p class=\"text-center mt-6 text-sm\" style=\"color: var(--text-secondary)\">Don't have an account? <a href=\"/register\" class=\"font-medium hover:underline\" style=\"color: var(--accent)\">Sign up</a></p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"bookhoard/internal/handlers"
|
||||
)
|
||||
|
||||
// The rendering-ownership partials must render standalone (they are
|
||||
// fetched by htmx.ajax at runtime) and must never leak templ literal
|
||||
// expressions into their markup.
|
||||
func TestRenderingPartialsRender(t *testing.T) {
|
||||
var b strings.Builder
|
||||
col := CollectionData{ID: "c1", Name: "Test Shelf", IsSmart: false}
|
||||
books := []handlers.BookInfo{{MediaItemID: "m1", Title: "A Book", Author: "An Author"}}
|
||||
if err := CollectionBooksGrid(col, books).Render(context.Background(), &b); err != nil {
|
||||
t.Fatalf("CollectionBooksGrid: %v", err)
|
||||
}
|
||||
out := b.String()
|
||||
for _, want := range []string{"A Book", `data-media-id="m1"`, "Remove from Collection"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("books grid missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty collection renders the empty state, not a literal loop.
|
||||
b.Reset()
|
||||
if err := CollectionBooksGrid(col, nil).Render(context.Background(), &b); err != nil {
|
||||
t.Fatalf("CollectionBooksGrid empty: %v", err)
|
||||
}
|
||||
if !strings.Contains(b.String(), "No books in this collection yet.") {
|
||||
t.Error("books grid empty state missing")
|
||||
}
|
||||
|
||||
b.Reset()
|
||||
user := User{ID: "u1", Timezone: "UTC"}
|
||||
conflicts := []handlers.ConflictDetailResponse{{ID: "cf1", MediaItemTitle: "War and Peace", ConflictType: "progress"}}
|
||||
if err := ConflictsRefreshFragment(user, conflicts, 3, 1).Render(context.Background(), &b); err != nil {
|
||||
t.Fatalf("ConflictsRefreshFragment: %v", err)
|
||||
}
|
||||
out = b.String()
|
||||
for _, want := range []string{`id="conflicts-list"`, `id="stats-bar"`, "hx-swap-oob", "Resolve"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("conflicts fragment missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The unlinked-books queue banner renders only when the queue is
|
||||
// non-empty (hidden-unless-needed).
|
||||
func TestDashboardUnlinkedBanner(t *testing.T) {
|
||||
render := func(count int) string {
|
||||
var b strings.Builder
|
||||
err := Dashboard(User{ID: "u1"}, nil, nil, nil, "", nil, 0, nil, "", "", count).Render(context.Background(), &b)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
if out := render(0); strings.Contains(out, "/unlinked-books") {
|
||||
t.Error("banner rendered with empty queue")
|
||||
}
|
||||
out := render(2)
|
||||
if !strings.Contains(out, "/unlinked-books") || !strings.Contains(out, "could not be identified") {
|
||||
t.Error("banner missing with non-empty queue")
|
||||
}
|
||||
}
|
||||
|
||||
// The unlinked-books page renders queue rows standalone.
|
||||
func TestUnlinkedBooksPageRender(t *testing.T) {
|
||||
var b strings.Builder
|
||||
user := User{ID: "u1", Timezone: "UTC"}
|
||||
books := []UnlinkedBookData{{
|
||||
ProgressID: "ub1",
|
||||
DeviceName: "Kobo Clara",
|
||||
DeviceType: "kobo",
|
||||
FilePath: "/mnt/onboard/book.epub",
|
||||
SHA256: "abc123",
|
||||
TitleFromDevice: "Some Sideloaded Book",
|
||||
LastSyncTime: "2026-10-18 12:00",
|
||||
}}
|
||||
if err := UnlinkedBooks(user, books).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"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("unlinked page missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ templ Profile(user User) {
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }" x-data="profile">
|
||||
<body class={ "theme-" + user.Theme } x-data="profile">
|
||||
@Header(user, "/profile")
|
||||
<main class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8 flex items-center gap-3">
|
||||
|
||||
@@ -36,7 +36,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 5, Col: 20}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 5, Col: 20}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -57,7 +57,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 21, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 21, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -70,7 +70,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 30, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 30, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -83,7 +83,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 39, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 39, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -96,7 +96,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 49, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 49, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -475,7 +475,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, 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: 191, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 191, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user