feat(series): multi-field search, sort-direction toggle, apply placement

Toolbar feedback round: the series search box now matches whatever the
main search matches for a group — series NAME, any book's AUTHOR, and
any book's TITLE inside the series (WHERE EXISTS per group-row, so
counts stay true); the separate author input is gone from the toolbar
(the author= param stays live for the JSON API and deep links). The
sort control becomes a base-key select (Date added/Name/Author/Books/
Progress/Last read) plus a chevron direction toggle that flips and
resubmits; sort/dir compose through ResolveSeriesSort (full registry
keys keep working for back-compat, unknown base keys fall to recent).
The search/apply button moved next to the search box as requested, and
default URLs stay clean (sort/dir only emitted once the user chooses).
This commit is contained in:
John O'Keefe committed 2026-10-27 19:05:23 -04:00
1 parent 14285ff314
commit 96c1228805
11 files changed
+504 -149

No files matched your search

+37 -1
View File
@@ -164,13 +164,49 @@ func TestSeriesSortAndFilter(t *testing.T) {
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames("&sort=bogus-key"))
})
t.Run("base sort + direction toggle composes registry keys", func(t *testing.T) {
// The toolbar sends base keys + dir; defaults apply when dir is
// absent, and both directions of every base key are reachable.
cases := []struct {
query string
want []string
}{
{"sort=name", []string{"Apple", "Mango", "Zebra"}}, // name asc default
{"sort=name&dir=desc", []string{"Zebra", "Mango", "Apple"}}, // toggle flips
{"sort=books&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // books desc default, flipped
{"sort=progress&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // progress desc default, flipped
{"sort=author", []string{"Mango", "Apple", "Zebra"}}, // author asc default
}
for _, tc := range cases {
got := renderedNames("&" + tc.query)
require.Len(t, got, 3, tc.query)
for i, want := range tc.want {
assert.Contains(t, got[i], want, "%s: position %d", tc.query, i)
}
}
})
t.Run("name query filters", func(t *testing.T) {
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=Mango"))
// ILIKE is case-insensitive and substring-shaped.
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=mango"))
})
t.Run("author query filters on any book's author", func(t *testing.T) {
t.Run("multi-field query matches authors and book titles", func(t *testing.T) {
// Author of Mango is "Alpha One" — the query no longer needs the
// separate author box.
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=alpha+one"))
// "Zebra Book 2" is a book TITLE inside the Zebra series: the
// series must surface for a title match too.
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&q=zebra+book+2"))
// Nothing matches anywhere.
assert.Empty(t, renderedNames("&q=nobody-nowhere"))
})
t.Run("author param (API back-compat) filters on any book's author", func(t *testing.T) {
// The toolbar no longer renders a separate author box (the
// multi-field q covers authors), but the param stays live for
// the JSON API and deep links.
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&author=alpha"))
assert.Empty(t, renderedNames("&author=nobody"))
})
+178
View File
@@ -0,0 +1,178 @@
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
const BASE = "http://localhost:8765";
const STAMP = Date.now();
const EMAIL = `e2e-search-${STAMP}@example.com`;
const USERNAME = `e2e-search-${STAMP}`;
const PASSWORD = "E2e-Playwright-2026!";
let token = "";
const consoleFailures: string[] = [];
function watchPage(page: Page): void {
page.on("pageerror", (err) => {
consoleFailures.push(`pageerror: ${err.message}`);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleFailures.push(`console.error: ${msg.text()}`);
}
});
}
function expectNoClientErrors(): void {
const bad = consoleFailures.filter((f) =>
/(is not defined|is not a function|headerSearch|alpine)/i.test(f),
);
expect(bad, `uncaught client errors:\n${bad.join("\n")}`).toEqual([]);
}
async function apiLogin(request: APIRequestContext): Promise<string> {
const res = await request.post("/api/auth/login", {
data: { login: EMAIL, password: PASSWORD },
});
expect(res.status(), "login should succeed").toBe(200);
const body = await res.json();
return body.access_token as string;
}
test.describe.configure({ mode: "serial" });
test.describe("Header search + audio scrubber (client wiring)", () => {
let page: Page;
test.beforeAll(async ({ browser, request }) => {
const reg = await request.post("/api/auth/register", {
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
});
expect([201, 200, 409]).toContain(reg.status());
token = await apiLogin(request);
expect(token).toBeTruthy();
page = await browser.newPage();
watchPage(page);
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
});
test.afterAll(async () => {
expectNoClientErrors();
await page?.close();
});
test("header search dropdown opens on typing (regression: tree-shaken module)", async () => {
await page.goto("/bookshelf");
await expect(page.locator("#header-search")).toBeVisible();
// The regression this pins: search.ts (which registers the
// headerSearch Alpine component) was tree-shaken from the bundle,
// so typing did nothing — no request, no dropdown. The component
// must be registered (x-data resolves) and the htmx swap must land.
await page.fill("#header-search", "a");
// Below the 2-char minimum: no results may appear.
await page.waitForTimeout(700);
await expect(page.locator("#search-results")).toBeHidden();
await page.fill("#header-search", "al");
const results = page.locator("#search-results");
await expect(results).toBeVisible({ timeout: 10_000 });
await expect(results.locator(".search-result-item").first()).toBeVisible({
timeout: 10_000,
});
});
test("audio scrubber: grabbing the handle does not teleport (plain and Shift)", async ({ request }) => {
// Find an audiobook in the shared dev library (plain media list,
// then filter client-side — the search endpoint's facet behavior
// with empty q isn't a listing API).
const res = await request.get("/api/media-items", {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status()).toBe(200);
const body = (await res.json()) as { data?: Array<{ id: string; format_group?: string }> };
const rows = (body.data ?? []).filter((r) => r.format_group === "audiobook");
test.skip(rows.length === 0, "no audiobooks in the dev library");
const bookId = rows[0].id;
await page.goto(`/readers/${bookId}`);
const scrubArea = page.locator("#scrub-area");
await expect(scrubArea).toBeVisible();
// The fresh user has never played this book — park the playhead at
// 30% via the progress API so the grab tests run mid-book.
const put = await request.put(`/api/media-items/${bookId}/progress`, {
headers: { Authorization: `Bearer ${token}` },
data: { percentage: 0.3, device_id: "e2e-scrubber" },
});
expect([200, 204]).toContain(put.status());
await page.reload();
await expect(scrubArea).toBeVisible();
const box = await scrubArea.boundingBox();
test.skip(!box, "scrub bar not laid out");
const y = box!.y + box!.height / 2;
// Where is the thumb right now? The native range input mirrors the
// playhead; read its aria-valuenow + max to compute the thumb X.
const thumbX = await page.evaluate(() => {
const input = document.getElementById("audio-scrub") as HTMLInputElement;
const area = document.getElementById("scrub-area")!;
const rect = area.getBoundingClientRect();
const value = Number(input.value || 0);
const max = Number(input.max || 1);
return rect.left + (value / max) * rect.width;
});
const readScrubValue = () =>
page.evaluate(() => Number((document.getElementById("audio-scrub") as HTMLInputElement).value));
const before = await readScrubValue();
expect(before).toBeGreaterThan(0);
// --- plain grab: pointerdown ON the thumb must not move it ---
await page.mouse.move(thumbX, y);
await page.mouse.down();
await page.waitForTimeout(150);
const atGrab = await readScrubValue();
expect(Math.abs(atGrab - before)).toBeLessThan(
Math.max(1, before * 0.0005),
"grabbing the handle must not jump the playhead",
);
// Drag left by 60px: the handle follows RELATIVELY.
await page.mouse.move(thumbX - 60, y, { steps: 5 });
await page.waitForTimeout(100);
const afterDrag = await readScrubValue();
expect(afterDrag).toBeLessThan(atGrab, "dragging left scrubs backward");
await page.mouse.up();
await page.waitForTimeout(400); // seek commits
// --- Shift+grab: the precision rail appears, no teleport ---
const railBefore = await readScrubValue();
// Re-locate the thumb after the commit.
const thumbX2 = await page.evaluate(() => {
const input = document.getElementById("audio-scrub") as HTMLInputElement;
const area = document.getElementById("scrub-area")!;
const rect = area.getBoundingClientRect();
const value = Number(input.value || 0);
const max = Number(input.max || 1);
return rect.left + (value / max) * rect.width;
});
await page.keyboard.down("Shift");
await page.mouse.move(thumbX2, y);
await page.mouse.down();
await page.waitForTimeout(150);
const railAtGrab = await readScrubValue();
expect(Math.abs(railAtGrab - railBefore)).toBeLessThan(
Math.max(1, railBefore * 0.0005),
"Shift+grab must not teleport either",
);
// The rail is the feedback for Shift+grab — it must be visible.
await expect(page.locator('div[x-show="railVisible"]')).toBeVisible();
await page.keyboard.up("Shift");
await page.mouse.up();
});
});
+22 -2
View File
@@ -5304,7 +5304,19 @@ WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
AND ($4::text IS NULL OR series ILIKE '%' || $4::text || '%')
-- Multi-field search (series page): the query matches the series
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
-- a WHERE filter: a group qualifies when ANY of its rows matches,
-- so COUNT(*) keeps reporting the whole series.
AND ($4::text IS NULL OR
series ILIKE '%' || $4::text || '%'
OR author ILIKE '%' || $4::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || $4::text || '%'
))
GROUP BY library_id, series
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
AND (
@@ -5425,7 +5437,15 @@ SELECT COUNT(*)::int FROM (
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
AND ($4::text IS NULL OR series ILIKE '%' || $4::text || '%')
AND ($4::text IS NULL OR
series ILIKE '%' || $4::text || '%'
OR author ILIKE '%' || $4::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || $4::text || '%'
))
GROUP BY library_id, series
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
AND (
+22 -2
View File
@@ -4104,7 +4104,19 @@ WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
AND (sqlc.narg('series_query')::text IS NULL OR series ILIKE '%' || sqlc.narg('series_query')::text || '%')
-- Multi-field search (series page): the query matches the series
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
-- a WHERE filter: a group qualifies when ANY of its rows matches,
-- so COUNT(*) keeps reporting the whole series.
AND (sqlc.narg('series_query')::text IS NULL OR
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR author ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || sqlc.narg('series_query')::text || '%'
))
GROUP BY library_id, series
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
AND (
@@ -4152,7 +4164,15 @@ SELECT COUNT(*)::int FROM (
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
AND (sqlc.narg('series_query')::text IS NULL OR series ILIKE '%' || sqlc.narg('series_query')::text || '%')
AND (sqlc.narg('series_query')::text IS NULL OR
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR author ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || sqlc.narg('series_query')::text || '%'
))
GROUP BY library_id, series
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
AND (
+1
View File
@@ -68,6 +68,7 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
// unknown values resolve to defaults inside the service.
opts := services.SeriesPageOptions{
Sort: c.QueryParam("sort"),
Dir: c.QueryParam("dir"),
Query: c.QueryParam("q"),
Author: c.QueryParam("author"),
Status: c.QueryParam("status"),
+16 -5
View File
@@ -151,17 +151,28 @@ func registerFrontendRoutes(cfg *Config) {
// Series-page state (URL is the source of truth — plain GET form,
// no htmx on this page). Unknown values resolve to defaults in the
// service's registries, so hand-edited URLs stay safe.
// service's registries, so hand-edited URLs stay safe. The URL
// carries the BASE sort key + direction; opts.Sort gets the
// composed registry key (full keys win on re-resolve). With no
// sort param at all, state stays empty so default URLs remain
// clean (the toolbar treats empty as recent/desc).
rawSort := c.QueryParam("sort")
sortKey, sortDir := services.ResolveSeriesSort(rawSort, c.QueryParam("dir"))
state := templates.SeriesPageState{
Sort: c.QueryParam("sort"),
Sort: services.SeriesSortBase(sortKey),
Dir: sortDir,
Query: c.QueryParam("q"),
Author: c.QueryParam("author"),
Status: c.QueryParam("status"),
}
if rawSort == "" {
state.Sort = ""
state.Dir = ""
}
opts := services.SeriesPageOptions{
Sort: state.Sort,
Sort: sortKey,
Dir: sortDir,
Query: state.Query,
Author: state.Author,
Author: c.QueryParam("author"), // API/deep-link back-compat; the toolbar's multi-field q covers authors
Status: state.Status,
}
if viewerID, vidErr := uuid.Parse(user.ID); vidErr == nil {
+73 -8
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
@@ -31,8 +32,9 @@ type SeriesInfo struct {
// stale URL or hand-rolled API call can never reach the SQL with an
// unhandled key.
type SeriesPageOptions struct {
Sort string // registry key, resolved by ResolveSeriesSort
Query string // series-name substring
Sort string // base toolbar key or full registry key
Dir string // asc | desc ("" = the base key's default)
Query string // multi-field: series name, author, or book title
Author string // matches when ANY book's author matches
Status string // "" | unstarted | reading | finished
ViewerUserID pgtype.UUID
@@ -65,12 +67,74 @@ var seriesStatusKeys = map[string]bool{
"finished": true,
}
// ResolveSeriesSort maps any unknown/empty sort to the default. It is
// the single source of truth backing both the SSR page and the JSON
// API — the SQL CASE cascade must contain an arm for every key here.
func ResolveSeriesSort(sort string) string {
// seriesBaseSorts is the toolbar's base sort keys (the <select>) with
// the direction each one STARTS in (the asc/desc toggle's initial
// state). Composed with ResolveSeriesSort into the full registry keys
// the SQL cascade implements.
var seriesBaseSorts = map[string]string{
"recent": "desc",
"name": "asc",
"author": "asc",
"books": "desc",
"progress": "desc",
"last_read": "desc",
}
// ResolveSeriesSort maps a sort value (either a FULL registry key like
// "name_desc" — back-compat with old URLs and the JSON API — or a BASE
// toolbar key composed with a direction) to the registry key the SQL
// cascade implements, plus the EFFECTIVE direction (for the toggle's
// arrow). Unknown values resolve to the default.
func ResolveSeriesSort(sort, dir string) (string, string) {
sort = strings.TrimSpace(strings.ToLower(sort))
dir = strings.ToLower(strings.TrimSpace(dir))
if dir != "asc" && dir != "desc" {
dir = ""
}
// Full registry key straight from the URL wins as-is.
if seriesSortKeys[sort] {
return sort
return sort, seriesDirOf(sort)
}
base := sort
if base == "" {
base = "recent"
}
defaultDir, known := seriesBaseSorts[base]
if !known {
base, defaultDir = "recent", "desc"
}
if dir == "" {
dir = defaultDir
}
if base == "recent" && dir == "desc" {
return "recent", "desc"
}
return base + "_" + dir, dir
}
// seriesDirOf derives the display direction of a full registry key.
func seriesDirOf(key string) string {
switch {
case strings.HasSuffix(key, "_asc"):
return "asc"
case strings.HasSuffix(key, "_desc"):
return "desc"
case key == "recent":
return "desc"
default:
if d, ok := seriesBaseSorts[key]; ok {
return d
}
return "desc"
}
}
// SeriesSortBase strips the direction suffix from a full registry key
// ("name_desc" → "name") for the toolbar's base-key <select>.
func SeriesSortBase(key string) string {
key = strings.TrimSuffix(strings.TrimSuffix(key, "_asc"), "_desc")
if _, ok := seriesBaseSorts[key]; ok {
return key
}
return "recent"
}
@@ -95,7 +159,8 @@ func NewSeriesService(db *database.Queries) *SeriesService {
// visibleLibraryIDs for an across-my-libraries page. Both filters compose:
// the handler validates any explicit library_id against the visible set.
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts SeriesPageOptions) ([]SeriesInfo, int, error) {
sort := nargText(ResolveSeriesSort(opts.Sort))
sortKey, _ := ResolveSeriesSort(opts.Sort, opts.Dir)
sort := nargText(sortKey)
status := nargText("")
if seriesStatusKeys[opts.Status] {
status = nargText(opts.Status)
+33 -31
View File
@@ -8,23 +8,19 @@ import (
// seriesSortOptions is the <select> mirror of the SQL CASE cascade in
// GetDistinctSeries and the registry in services.ResolveSeriesSort —
// every value here must have a matching SQL arm and registry entry.
// every value here must have a matching base sort in
// services.seriesBaseSorts (the direction toggle composes the full
// registry key).
var seriesSortOptions = []struct {
Value string
Label string
}{
{"recent", "Recently added"},
{"recent_asc", "Oldest added"},
{"name_asc", "Name (A–Z)"},
{"name_desc", "Name (Z–A)"},
{"author_asc", "Author (A–Z)"},
{"author_desc", "Author (Z–A)"},
{"books_desc", "Most books"},
{"books_asc", "Fewest books"},
{"progress_desc", "Most complete"},
{"progress_asc", "Least complete"},
{"last_read_desc", "Recently read"},
{"last_read_asc", "Least recently read"},
{"recent", "Date added"},
{"name", "Name"},
{"author", "Author"},
{"books", "Books"},
{"progress", "Progress"},
{"last_read", "Last read"},
}
// seriesStatusOptions is the progress-filter <select> (mirror of the
@@ -49,12 +45,12 @@ func seriesPageURL(state SeriesPageState, libraryID string, page int) string {
if state.Sort != "" {
vals.Set("sort", state.Sort)
}
if state.Dir != "" {
vals.Set("dir", state.Dir)
}
if state.Query != "" {
vals.Set("q", state.Query)
}
if state.Author != "" {
vals.Set("author", state.Author)
}
if state.Status != "" {
vals.Set("status", state.Status)
}
@@ -66,7 +62,7 @@ func seriesPageURL(state SeriesPageState, libraryID string, page int) string {
// page's defaults — used to swap the empty state for a
// "no matches" + clear-filters variant.
func hasActiveSeriesFilters(state SeriesPageState) bool {
return state.Query != "" || state.Author != "" || state.Status != "" || (state.Sort != "" && state.Sort != "recent")
return state.Query != "" || state.Status != "" || (state.Sort != "" && state.Sort != "recent")
}
templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, state SeriesPageState, errorMessage string) {
@@ -108,19 +104,15 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
type="text"
name="q"
value={ state.Query }
placeholder="Search series…"
placeholder="Search series, authors, titles…"
class="input w-full pl-9"
aria-label="Search series by name"
aria-label="Search series by name, author, or title"
/>
</div>
<input
type="text"
name="author"
value={ state.Author }
placeholder="Author"
class="input w-40"
aria-label="Filter series by author"
/>
<button type="submit" class="btn btn-secondary" aria-label="Search">
@Icon("search", "h-4 w-4")
<span>Search</span>
</button>
<select
name="status"
class="input w-auto cursor-pointer"
@@ -134,16 +126,26 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
<select
name="sort"
class="input w-auto cursor-pointer"
aria-label="Sort series"
aria-label="Sort series by"
@change="$el.form.requestSubmit()"
>
for _, opt := range seriesSortOptions {
<option value={ opt.Value } selected?={ (state.Sort == "" && opt.Value == "recent") || state.Sort == opt.Value }>{ opt.Label }</option>
}
</select>
<button type="submit" class="btn btn-secondary" aria-label="Apply filters">
@Icon("filter", "h-4 w-4")
<span>Apply</span>
<input type="hidden" name="dir" value={ state.Dir } x-ref="sortDir"/>
<button
type="button"
class="btn btn-secondary px-3"
aria-label="Toggle sort direction"
title="Toggle sort direction"
@click="flipSortDir()"
>
if state.Dir == "asc" {
@Icon("chevron-up", "h-4 w-4")
} else {
@Icon("chevron-down", "h-4 w-4")
}
</button>
</form>
if len(seriesList) == 0 {
+109 -98
View File
@@ -16,23 +16,19 @@ import (
// seriesSortOptions is the <select> mirror of the SQL CASE cascade in
// GetDistinctSeries and the registry in services.ResolveSeriesSort —
// every value here must have a matching SQL arm and registry entry.
// every value here must have a matching base sort in
// services.seriesBaseSorts (the direction toggle composes the full
// registry key).
var seriesSortOptions = []struct {
Value string
Label string
}{
{"recent", "Recently added"},
{"recent_asc", "Oldest added"},
{"name_asc", "Name (A–Z)"},
{"name_desc", "Name (Z–A)"},
{"author_asc", "Author (A–Z)"},
{"author_desc", "Author (Z–A)"},
{"books_desc", "Most books"},
{"books_asc", "Fewest books"},
{"progress_desc", "Most complete"},
{"progress_asc", "Least complete"},
{"last_read_desc", "Recently read"},
{"last_read_asc", "Least recently read"},
{"recent", "Date added"},
{"name", "Name"},
{"author", "Author"},
{"books", "Books"},
{"progress", "Progress"},
{"last_read", "Last read"},
}
// seriesStatusOptions is the progress-filter <select> (mirror of the
@@ -57,12 +53,12 @@ func seriesPageURL(state SeriesPageState, libraryID string, page int) string {
if state.Sort != "" {
vals.Set("sort", state.Sort)
}
if state.Dir != "" {
vals.Set("dir", state.Dir)
}
if state.Query != "" {
vals.Set("q", state.Query)
}
if state.Author != "" {
vals.Set("author", state.Author)
}
if state.Status != "" {
vals.Set("status", state.Status)
}
@@ -74,7 +70,7 @@ func seriesPageURL(state SeriesPageState, libraryID string, page int) string {
// page's defaults — used to swap the empty state for a
// "no matches" + clear-filters variant.
func hasActiveSeriesFilters(state SeriesPageState) bool {
return state.Query != "" || state.Author != "" || state.Status != "" || (state.Sort != "" && state.Sort != "recent")
return state.Query != "" || state.Status != "" || (state.Sort != "" && state.Sort != "recent")
}
func Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, state SeriesPageState, errorMessage string) templ.Component {
@@ -147,7 +143,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentLibraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 102, Col: 69}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 98, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -168,26 +164,21 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(state.Query)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 110, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 106, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" placeholder=\"Search series…\" class=\"input w-full pl-9\" aria-label=\"Search series by name\"></div><input type=\"text\" name=\"author\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" placeholder=\"Search series, authors, titles…\" class=\"input w-full pl-9\" aria-label=\"Search series by name, author, or title\"></div><button type=\"submit\" class=\"btn btn-secondary\" aria-label=\"Search\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(state.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 119, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" placeholder=\"Author\" class=\"input w-40\" aria-label=\"Filter series by author\"> <select name=\"status\" class=\"input w-auto cursor-pointer\" aria-label=\"Filter series by your progress\" @change=\"$el.form.requestSubmit()\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span>Search</span></button> <select name=\"status\" class=\"input w-auto cursor-pointer\" aria-label=\"Filter series by your progress\" @change=\"$el.form.requestSubmit()\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -196,12 +187,12 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Value)
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 131, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 123, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -219,12 +210,12 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 131, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 123, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -233,7 +224,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</select> <select name=\"sort\" class=\"input w-auto cursor-pointer\" aria-label=\"Sort series\" @change=\"$el.form.requestSubmit()\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</select> <select name=\"sort\" class=\"input w-auto cursor-pointer\" aria-label=\"Sort series by\" @change=\"$el.form.requestSubmit()\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -242,12 +233,12 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Value)
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 141, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 133, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -265,12 +256,12 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 141, Col: 132}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 133, Col: 132}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -279,21 +270,41 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</select> <button type=\"submit\" class=\"btn btn-secondary\" aria-label=\"Apply filters\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</select> <input type=\"hidden\" name=\"dir\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("filter", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(state.Dir)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 136, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span>Apply</span></button></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" x-ref=\"sortDir\"> <button type=\"button\" class=\"btn btn-secondary px-3\" aria-label=\"Toggle sort direction\" title=\"Toggle sort direction\" @click=\"flipSortDir()\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if state.Dir == "asc" {
templ_7745c5c3_Err = Icon("chevron-up", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = Icon("chevron-down", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(seriesList) == 0 {
if hasActiveSeriesFilters(state) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div id=\"series-empty\" class=\"card text-center py-16 px-6\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div id=\"series-empty\" class=\"card text-center py-16 px-6\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -301,25 +312,25 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Series Match</h3><p class=\"mb-4\" style=\"color: var(--text-secondary)\">Nothing matches the current search or filters</p><a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Series Match</h3><p class=\"mb-4\" style=\"color: var(--text-secondary)\">Nothing matches the current search or filters</p><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 templ.SafeURL
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(seriesPageURL(SeriesPageState{}, currentLibraryID, 1))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 157, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 159, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"btn btn-secondary\">Clear filters</a></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" class=\"btn btn-secondary\">Clear filters</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div id=\"series-empty\" class=\"card text-center py-16 px-6\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div id=\"series-empty\" class=\"card text-center py-16 px-6\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -327,13 +338,13 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Series Found</h3><p style=\"color: var(--text-secondary)\">Books with series metadata will appear here</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Series Found</h3><p style=\"color: var(--text-secondary)\">Books with series metadata will appear here</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div id=\"series-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div id=\"series-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -343,30 +354,30 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if totalPages > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div id=\"series-pagination\" class=\"flex justify-center items-center gap-3 mt-10\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div id=\"series-pagination\" class=\"flex justify-center items-center gap-3 mt-10\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if currentPage > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(seriesPageURL(state, currentLibraryID, currentPage-1))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 180, Col: 69}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 182, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" class=\"btn btn-secondary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -374,56 +385,56 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span>Previous</span></a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<span>Previous</span></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<span class=\"text-sm\" style=\"color: var(--text-secondary)\">Page ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span class=\"text-sm\" style=\"color: var(--text-secondary)\">Page ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", currentPage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 188, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 190, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " of ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " of ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 188, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 190, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if currentPage < totalPages {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(seriesPageURL(state, currentLibraryID, currentPage+1))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 192, Col: 69}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 194, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" class=\"btn btn-secondary\"><span>Next</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" class=\"btn btn-secondary\"><span>Next</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -431,17 +442,17 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</main></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</main></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -449,7 +460,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -478,20 +489,20 @@ func SeriesCard(series SeriesCardData) templ.Component {
templ_7745c5c3_Var16 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(series.Name) + "&library_id=" + url.QueryEscape(series.LibraryID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 209, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 211, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" class=\"block\"><div class=\"series-card card rounded-2xl overflow-hidden cursor-pointer\" style=\"box-shadow: var(--shadow-card);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" class=\"block\"><div class=\"series-card card rounded-2xl overflow-hidden cursor-pointer\" style=\"box-shadow: var(--shadow-card);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -500,7 +511,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -513,7 +524,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -523,20 +534,20 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<img src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(cover)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 214, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 216, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -549,26 +560,26 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" alt=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(series.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 216, Col: 23}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 218, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" loading=\"lazy\"> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" loading=\"lazy\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(series.CoverPaths) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"cover-img cover-0 flex items-center justify-center\" style=\"background: var(--bg-primary); color: var(--text-secondary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div class=\"cover-img cover-0 flex items-center justify-center\" style=\"background: var(--bg-primary); color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -576,87 +587,87 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div><div class=\"series-card-info px-3 py-2\"><h3 class=\"font-semibold text-sm line-clamp-2\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</div><div class=\"series-card-info px-3 py-2\"><h3 class=\"font-semibold text-sm line-clamp-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 228, Col: 99}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 230, Col: 99}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</h3><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</h3><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.BookCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 230, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 232, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " of ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " of ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.TotalInSeries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 230, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 232, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " books</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " books</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if series.ReadCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<div class=\"mt-1.5 h-1 rounded-full overflow-hidden\" style=\"background: var(--bg-primary);\"><div class=\"h-full rounded-full\" style=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"mt-1.5 h-1 rounded-full overflow-hidden\" style=\"background: var(--bg-primary);\"><div class=\"h-full rounded-full\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %d%%; background-color: var(--accent);", readPercent(series.ReadCount, series.BookCount)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 236, Col: 124}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 238, Col: 124}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"></div></div><p class=\"text-[10px] mt-0.5\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\"></div></div><p class=\"text-[10px] mt-0.5\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d of %d read", series.ReadCount, series.BookCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 240, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 242, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</div></div></a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</div></div></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+3 -2
View File
@@ -149,11 +149,12 @@ type SeriesCardData struct {
// SeriesPageState is the /series page's sort/filter state, echoed back
// into the toolbar controls and the pagination URLs (the URL is the
// single source of truth on this plain-GET page).
// single source of truth on this plain-GET page). Sort holds the BASE
// toolbar key (name/author/…); Dir is the effective direction.
type SeriesPageState struct {
Sort string
Dir string
Query string
Author string
Status string
}
+10
View File
@@ -26,4 +26,14 @@ Alpine.data("seriesPage", () => ({
onSwitch: navigateToSeries,
});
},
// flipSortDir inverts the direction hidden input and resubmits the
// plain-GET filter form (the URL stays the single source of truth).
flipSortDir() {
const input = this.$refs.sortDir as HTMLInputElement | undefined;
if (!input) return;
input.value = input.value === "asc" ? "desc" : "asc";
const form = input.closest("form") as HTMLFormElement | null;
if (form) form.requestSubmit();
},
}));