diff --git a/cmd/server/tests/series_sort_filter_test.go b/cmd/server/tests/series_sort_filter_test.go index 3170c1a1..3c385f87 100644 --- a/cmd/server/tests/series_sort_filter_test.go +++ b/cmd/server/tests/series_sort_filter_test.go @@ -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")) }) diff --git a/e2e/search-scrubber.spec.ts b/e2e/search-scrubber.spec.ts new file mode 100644 index 00000000..6b8cc9c7 --- /dev/null +++ b/e2e/search-scrubber.spec.ts @@ -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 { + 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(); + }); +}); diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 652cc2db..2aba58bd 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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 ( diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index b7b836b3..cb2cd98d 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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 ( diff --git a/internal/handlers/series.go b/internal/handlers/series.go index c0996e20..b4817fc5 100644 --- a/internal/handlers/series.go +++ b/internal/handlers/series.go @@ -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"), diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 708c6810..09492ee4 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -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 { diff --git a/internal/services/series_service.go b/internal/services/series_service.go index f5319d89..0181bf7a 100644 --- a/internal/services/series_service.go +++ b/internal/services/series_service.go @@ -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 . +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) diff --git a/templates/series.templ b/templates/series.templ index 609f261f..8f346af0 100644 --- a/templates/series.templ +++ b/templates/series.templ @@ -8,23 +8,19 @@ import ( // seriesSortOptions is the (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" /> - + - if len(seriesList) == 0 { diff --git a/templates/series_templ.go b/templates/series_templ.go index 0cf734bd..2dd4612d 100644 --- a/templates/series_templ.go +++ b/templates/series_templ.go @@ -16,23 +16,19 @@ import ( // seriesSortOptions is the (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\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" x-ref=\"sortDir\"> ") 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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") 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, "

No Series Match

Nothing matches the current search or filters

No Series Match

Nothing matches the current search or filters

Clear filters
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" class=\"btn btn-secondary\">Clear filters
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") 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, "

No Series Found

Books with series metadata will appear here

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

No Series Found

Books with series metadata will appear here

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") 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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if totalPages > 1 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if currentPage > 1 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + 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, "Previous ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "Previous ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "Page ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "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, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if currentPage < totalPages { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Next") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" class=\"btn btn-secondary\">Next") 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, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") 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, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "") 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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" class=\"block\">
") 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, "
") + 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, "\"") ") + 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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") 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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "

") 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, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "

") 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

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " books

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if series.ReadCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\">

") 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, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index 6fdedd7f..6dcac8ae 100644 --- a/templates/types.go +++ b/templates/types.go @@ -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 } diff --git a/web/src/series.ts b/web/src/series.ts index fecffd49..9c45ba87 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -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(); + }, }));