Compare commits

...
9 Commits
Author SHA1 Message Date
John O'Keefe f7dc8189a1 feat(css): cross-document view transitions
Release / build-and-push (push) Successful in 3m15s
@view-transition { navigation: auto; } lets Chrome 126+ / Safari 18+
crossfade same-origin navigations — sidebar links and the series
page's library-switch navigation get a subtle fade with zero
JavaScript, softening the switch to full SSR navigation. Pure
progressive enhancement: Firefox and older browsers simply navigate
instantly. style.css regenerated via build:css:prod.
2026-10-19 12:40:44 -04:00
John O'Keefe a8641ab343 chore(ui): bump the main.js cache-bust stamp to v6
web/static/*.js is gitignored and built at image-build time, so the
hardcoded ?v= on the module script tag is the ONLY mechanism that
evicts a stale bundle from browsers. Shipping the series twin deletion
without a bump would let cached main.js (with the deleted renderer)
keep running on prod, making the paginator fix appear not to work.
Found while shipping; easy to miss because the bundle never appears in
git status.
2026-10-19 12:40:31 -04:00
John O'Keefe 1ce66b2213 chore(templ): warn against change handlers on the bookshelf library select
The bookshelf select (#library-select) sits inside the htmx filter
form and the SSR route honors ?offset= — wiring a change handler or
hx-trigger="change" on it would let header.ts's synthetic restore
event re-fetch the grid at offset 0, the same stomp class that broke
the /series paginator. Comment-only guard; regenerated
bookshelf_templ.go carries it into the emitted markup.
2026-10-19 12:40:13 -04:00
John O'Keefe 18f52d240b docs(agents): record the dashboard sections rendering exception
renderDashboardCollections/renderSectionHTML re-render dashboard
sections from /api/dashboard/sections JSON on library switch, settings
save, and refresh — deliberately kept in TypeScript to preserve the
switch fade on the app's most interactive page. First-paint section
markup remains templ (Dashboard handler in internal/router/frontend.go).

Recorded in the Rendering ownership section as a sanctioned exception
so it is not silently 'fixed' without restoring a templ-partial fade
path, and so the pattern is not cloned to new pages. (User-directed:
the dashboard keeps its fade; every other page stays templ-rendered.)
2026-10-19 12:40:02 -04:00
John O'Keefe 220d97cfe0 test(series): pin the SSR pagination contract under tied timestamps
New integration test mirroring a prod bulk import: a dedicated test
library with 30 series whose items share ONE identical created_at
(single-transaction NOW()), the exact data shape that made prod
pagination look broken.

Pins, via the real SSR /series route:
- page 1 renders exactly 24 deterministic series (01-24) with chip
  "Page 1 of 2", no Previous, and a Next href carrying library_id and
  page (templ escapes & as & inside attributes)
- page 2 renders the remaining 6, disjoint from page 1, chip
  "Page 2 of 2", no Next, Previous href correct
- missing page param defaults to page 1; page=0/-3/two clamp to page 1

Guards both the SQL tiebreaker and the /series handler contract;
would fail on any return of the stomp or an unstable sort.
2026-10-19 12:39:47 -04:00
John O'Keefe 38efbff7e1 fix(db): total ordering for series OFFSET pagination
GetDistinctSeries ordered by MAX(created_at) DESC alone. Bulk imports
inserted in a single transaction share one NOW() timestamp, leaving
large numbers of (library_id, series) groups tied on MAX(created_at);
Postgres does not guarantee tie order between executions, so
LIMIT/OFFSET pages could repeat or drop series across page loads.

Add the GROUP BY keys (library_id, series) as tiebreakers so the sort
is total and deterministic. Comment documents why the tiebreaker is
required so it is not dropped as noise. sqlc regenerated (interface
unchanged; the comment flows into querier.go docs).
2026-10-19 12:39:32 -04:00
John O'Keefe f4414aa5b6 style(series): plain-text page indicator instead of the chip pill
"Page N of M" now renders as small secondary-color text on the page
background rather than an accent chip (user preference; the old TS
twin happened to render this way and the chip was only being seen
after JS took over). With the twin deleted, this templ markup is the
only paginator rendering, so the look is stable.
2026-10-19 12:39:20 -04:00
John O'Keefe 3daa834a94 refactor(series): drop the TS rendering twin; library switch navigates SSR
web/src/series.ts carried a full TypeScript twin of the templ page —
cards and paginator rendered via innerHTML from /api/series JSON,
always hardcoded to page 1. Combined with the header restore dispatch
(previous commit) it made every /series load stomp server-rendered
pagination back to page 1, which is the paginator bug reported on prod
(9 pages of series, Next kept landing on page 1, per-library only).

Rendering ownership (AGENTS.md): this page now has NO client renderer.
- grid and paginator are rendered only by templates/series.templ
- a library switch is a plain SSR navigation to
  /series?library_id=...&page=1, keeping the URL the single source of
  truth for both library and page state
- drops the duplicate /api/series fetch that ran on every page load

rendering-allowlist.txt: series.ts baseline entry removed — the file
no longer builds HTML anywhere.
2026-10-19 12:39:09 -04:00
John O'Keefe 202a090811 fix(ui): library restore no longer re-fires change on synced dropdowns
restoreLibrarySelection() ran on every page load via the header's
x-init and always dispatched a synthetic `change` on #library-select
after setting its value. On SSR pages the dropdown already renders the
stored library (setSelectedLibrary keeps the cookie in sync and
resolveLibrary renders from it), so the synthetic event re-ran the
page's switch handler, whose fetches are hardcoded to page 1 /
offset 0 — stomping freshly rendered content.

Visible impact: the /series paginator snapped back to page 1 on every
navigation whenever a specific library was stored (All Libraries
short-circuits restore, which is why it only failed per-library).
Silent impact: dashboard and collection-detail pages re-fetched their
content on every single load.

The restore now re-fires `change` only when the dropdown genuinely
drifted from storage; the common synced case is a visual no-op.
2026-10-19 12:38:50 -04:00
16 changed files with 203 additions and 145 deletions

No files matched your search

+9
View File
@@ -32,6 +32,15 @@ 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 in TypeScript is a defect; if a templ twin of it exists, one of the two
must be deleted. 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 ## Library visibility (family-lens rule) — ALWAYS apply
Per-user library visibility (`library_visibility` table, default-ALLOW: Per-user library visibility (`library_visibility` table, default-ALLOW:
+115
View File
@@ -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 &amp; inside attribute values.
assert.Contains(t, body, fmt.Sprintf(`href="/series?library_id=%s&amp;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&amp;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)
}
})
}
+5
View File
@@ -221,6 +221,11 @@ type Querier interface {
// Series are grouped PER LIBRARY: the same series name in two libraries // Series are grouped PER LIBRARY: the same series name in two libraries
// (novels vs manga adaptations, LN splits) stays two entries. Visible // (novels vs manga adaptations, LN splits) stays two entries. Visible
// filter enforces per-user library visibility at the source. // filter enforces per-user library visibility at the source.
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
// transaction share a single NOW() timestamp, leaving many groups tied on
// 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) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error) GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error)
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error) GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
+6 -1
View File
@@ -3840,7 +3840,7 @@ WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
AND missing_scan_count = 0 AND missing_scan_count = 0
AND series IS NOT NULL AND series != '' AND series IS NOT NULL AND series != ''
GROUP BY library_id, 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 LIMIT $4 OFFSET $3
` `
@@ -3862,6 +3862,11 @@ type GetDistinctSeriesRow struct {
// Series are grouped PER LIBRARY: the same series name in two libraries // Series are grouped PER LIBRARY: the same series name in two libraries
// (novels vs manga adaptations, LN splits) stays two entries. Visible // (novels vs manga adaptations, LN splits) stays two entries. Visible
// filter enforces per-user library visibility at the source. // filter enforces per-user library visibility at the source.
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
// transaction share a single NOW() timestamp, leaving many groups tied on
// 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) { func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) {
rows, err := q.db.Query(ctx, GetDistinctSeries, rows, err := q.db.Query(ctx, GetDistinctSeries,
arg.LibraryID, arg.LibraryID,
+6 -1
View File
@@ -2880,7 +2880,12 @@ WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_
AND missing_scan_count = 0 AND missing_scan_count = 0
AND series IS NOT NULL AND series != '' AND series IS NOT NULL AND series != ''
GROUP BY library_id, 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'); LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: GetDistinctSeriesCount :one -- name: GetDistinctSeriesCount :one
+6
View File
@@ -139,6 +139,12 @@ templ BookShelf(
<div class="p-6 space-y-5"> <div class="p-6 space-y-5">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library</label> <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"> <select id="library-select" name="library_id" class="input">
if len(libraries) == 0 { if len(libraries) == 0 {
<option value="">No libraries available</option> <option value="">No libraries available</option>
+19 -19
View File
@@ -177,7 +177,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -195,7 +195,7 @@ func BookShelf(
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries)) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -213,7 +213,7 @@ func BookShelf(
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries)) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 149, Col: 68} 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -234,7 +234,7 @@ func BookShelf(
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -247,7 +247,7 @@ func BookShelf(
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 57}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -260,7 +260,7 @@ func BookShelf(
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 77} 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -278,7 +278,7 @@ func BookShelf(
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -291,7 +291,7 @@ func BookShelf(
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 48} 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -304,7 +304,7 @@ func BookShelf(
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 68} 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -323,7 +323,7 @@ func BookShelf(
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 34} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 34}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -336,7 +336,7 @@ func BookShelf(
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 56} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 56}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -349,7 +349,7 @@ func BookShelf(
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 76} 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -367,7 +367,7 @@ func BookShelf(
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 34} return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 34}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -380,7 +380,7 @@ func BookShelf(
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 47} 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -393,7 +393,7 @@ func BookShelf(
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 67} 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -422,7 +422,7 @@ func BookShelf(
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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_Var20)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -445,7 +445,7 @@ func BookShelf(
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit)) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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_Var21) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -476,7 +476,7 @@ func BookShelf(
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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_Var22)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -489,7 +489,7 @@ func BookShelf(
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit)) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `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_Var23) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+1 -1
View File
@@ -241,7 +241,7 @@ templ Header(user User, currentPath string) {
</header> </header>
<!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate <!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate
builds across app restarts (browser caches are keyed by URL) --> builds across app restarts (browser caches are keyed by URL) -->
<script type="module" src="/static/main.js?v=5"></script> <script type="module" src="/static/main.js?v=6"></script>
</div> </div>
} }
+1 -1
View File
@@ -751,7 +751,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -54,7 +54,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
<span>Previous</span> <span>Previous</span>
</a> </a>
} }
<span class="chip"> <span class="text-sm" style="color: var(--text-secondary)">
Page { fmt.Sprintf("%d", currentPage) } of { fmt.Sprintf("%d", totalPages) } Page { fmt.Sprintf("%d", currentPage) } of { fmt.Sprintf("%d", totalPages) }
</span> </span>
if currentPage < totalPages { if currentPage < totalPages {
+1 -1
View File
@@ -140,7 +140,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"chip\">Page ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"text-sm\" style=\"color: var(--text-secondary)\">Page ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+8
View File
@@ -32,6 +32,14 @@ const restoreLibrarySelection = (): void => {
if (dropdown) { if (dropdown) {
const option = dropdown.querySelector(`option[value="${savedLibrary}"]`); const option = dropdown.querySelector(`option[value="${savedLibrary}"]`);
if (option) { if (option) {
// SSR pages already render the select with the stored library
// (setSelectedLibrary keeps the cookie in sync and resolveLibrary
// renders from it). Re-firing `change` here made every page load
// re-run the page's switch handler — which re-fetches content at
// hardcoded page 1 / offset 0 and stomps URL-tracked state (this
// broke the /series paginator: page 2 snapped back to page 1).
// Only re-fire when the select actually drifted from storage.
if (dropdown.value === savedLibrary) return;
dropdown.value = savedLibrary; dropdown.value = savedLibrary;
dropdown.dispatchEvent(new Event("change", { bubbles: true })); dropdown.dispatchEvent(new Event("change", { bubbles: true }));
console.log("Restored library selection:", savedLibrary); console.log("Restored library selection:", savedLibrary);
-1
View File
@@ -20,7 +20,6 @@ web/src/dom.ts:1
web/src/password_validation.ts:1 web/src/password_validation.ts:1
web/src/queue.ts:3 web/src/queue.ts:3
web/src/search.ts:4 web/src/search.ts:4
web/src/series.ts:1
web/src/setup.ts:1 web/src/setup.ts:1
web/src/stats.ts:9 web/src/stats.ts:9
web/src/toast.ts:2 web/src/toast.ts:2
+12 -118
View File
@@ -1,128 +1,22 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { initLibrarySwitcher } from "./library-switcher";
import { initLibrarySwitcher, switchWithTransition } from "./library-switcher";
interface SeriesItem { // Rendering ownership (AGENTS.md): the series grid and its paginator are
name: string; // rendered by templates/series.templ. This page intentionally has NO
library_id: string; // TypeScript renderer — the old one here duplicated the templ markup via
book_count: number; // innerHTML and re-fetched at hardcoded page 1, stomping SSR pagination
total_in_series: number; // whenever the header's library restore fired. A library switch is a plain
cover_paths: string[]; // SSR navigation so the URL stays the single source of truth for
last_entry_at: string; // library_id and page state.
} function navigateToSeries(libraryId: string): void {
const param = libraryId ? `library_id=${encodeURIComponent(libraryId)}&` : "";
function renderSeriesCard(series: SeriesItem): string { window.location.href = `/series?${param}page=1`;
const href = `/series/detail?name=${encodeURIComponent(series.name)}&library_id=${encodeURIComponent(series.library_id ?? "")}`;
const coverCount = series.cover_paths.length;
const coverClass = `cover-count-${coverCount}`;
let coversHtml = "";
for (let i = 0; i < coverCount; i++) {
coversHtml += `<img src="${series.cover_paths[i]}" class="cover-img cover-${i}" alt="${series.name}" loading="lazy" onerror="this.src='/static/placeholder-book.svg'">`;
}
if (coverCount === 0) {
coversHtml = `<div class="cover-img cover-0 flex items-center justify-center" style="background: var(--bg-primary);"><span class="text-3xl">📚</span></div>`;
}
return `
<a href="${href}" class="block">
<div class="series-card rounded-lg overflow-hidden cursor-pointer hover:shadow-lg transition-shadow" style="background-color: var(--bg-secondary);">
<div class="stacked-covers ${coverClass}">
${coversHtml}
</div>
<div class="series-card-info px-3 py-2">
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">${series.name}</h3>
<p class="text-xs mt-1" style="color: var(--text-secondary)">
${series.book_count} of ${series.total_in_series} books
</p>
</div>
</div>
</a>`;
}
function renderSeriesContent(
seriesList: SeriesItem[],
libraryId: string,
totalPages: number,
currentPage: number,
): string {
if (seriesList.length === 0) {
return `
<main class="w-full px-4 py-8">
<h1 class="text-3xl font-bold mb-6" style="color: var(--text-primary)">Series</h1>
<div class="text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📚</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Series Found</h3>
<p>Books with series metadata will appear here</p>
</div>
</main>`;
}
const cardsHtml = seriesList.map((s) => renderSeriesCard(s)).join("");
let paginationHtml = "";
if (totalPages > 1) {
const libParam = libraryId ? `library_id=${libraryId}&` : "";
const prevLink =
currentPage > 1
? `<a href="/series?${libParam}page=${currentPage - 1}" class="px-4 py-2 rounded-lg border" style="border-color: var(--border); color: var(--text-primary);">← Previous</a>`
: "";
const nextLink =
currentPage < totalPages
? `<a href="/series?${libParam}page=${currentPage + 1}" class="px-4 py-2 rounded-lg border" style="border-color: var(--border); color: var(--text-primary);">Next →</a>`
: "";
paginationHtml = `
<div class="flex justify-center items-center gap-4 mt-8">
${prevLink}
<span class="text-sm" style="color: var(--text-secondary)">Page ${currentPage} of ${totalPages}</span>
${nextLink}
</div>`;
}
return `
<main class="w-full px-4 py-8">
<h1 class="text-3xl font-bold mb-6" style="color: var(--text-primary)">Series</h1>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
${cardsHtml}
</div>
${paginationHtml}
</main>`;
}
async function switchLibrary(libraryId: string): Promise<void> {
await switchWithTransition("series-container", async () => {
const param = libraryId ? `library_id=${libraryId}&` : "";
const response = await fetch(
`/api/series?${param}limit=24&offset=0`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to load series");
}
const data = await response.json();
const seriesList: SeriesItem[] = data.series || [];
const total: number = data.total || 0;
const totalPages = Math.max(1, Math.ceil(total / 24));
const container = document.getElementById("series-container");
if (container) {
container.innerHTML = renderSeriesContent(seriesList, libraryId, totalPages, 1);
}
});
} }
Alpine.data("seriesPage", () => ({ Alpine.data("seriesPage", () => ({
initSeriesPage() { initSeriesPage() {
initLibrarySwitcher({ initLibrarySwitcher({
onSwitch: switchLibrary, onSwitch: navigateToSeries,
}); });
}, },
})); }));
+12
View File
@@ -3,6 +3,18 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/*
* Cross-document view transitions (the optional sweetener): modern
* browsers (Chrome 126+, Safari 18+) crossfade same-origin navigations —
* sidebar links and the series page's library-switch navigation get a
* subtle fade for free, with zero JavaScript. Firefox and older browsers
* simply navigate instantly; the rule is pure progressive enhancement.
*/
@view-transition {
navigation: auto;
}
@layer base { @layer base {
/* /*
* Default :root mirrors the Tokyo Night theme so the palette is correct * Default :root mirrors the Tokyo Night theme so the palette is correct
+1 -1
View File
@@ -1,2 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated /*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated