feat(series): sort and filter the series page
The /series page rendered its grid in one fixed order (newest first)
with no way to find a series beyond eyeballing cards. The page now has
a toolbar — series-name search, author filter, viewer-progress filter
and a 12-key sort select — wired as a plain GET form (the page's
existing model: the URL is the single source of truth, and the library
switcher now carries q/author/status/sort along on switch).
Extensibility is deliberate (user request): sorts live in a registry
(services.ResolveSeriesSort) mirrored by a CASE cascade in
GetDistinctSeries and a <select> in series.templ — a new sort is one
entry in each. The sort value is a BOUND PARAM compared inside the
cascade, never interpolated; unknown keys resolve to the default.
Filters: series-name matches in WHERE (the group key, so counts stay
true); author (BOOL_OR any-book match) and status (finished/reading/
unstarted vs the viewer's progress, same 0.999 threshold as the
filter_query status arms) in HAVING so COUNT(*) keeps reporting the
whole series. GetDistinctSeriesCount shares the JOIN + HAVING so
pagination totals match. The reading_progress join can't multiply rows
(the table is UNIQUE per media_item+user).
Cards gain the viewer's finished-count ("X of N read" + progress bar),
and the empty state distinguishes "no series at all" from "no matches —
clear filters". The JSON /api/series endpoint takes the same params and
gains read_count/last_read_at additively.
Integration test pins every sort key against a three-series fixture
with distinct created/author/count/progress answers, the filter matrix,
composition, unknown-sort fallback, card read-counts and the JSON
contract — all as the fresh regular user so the live reader's data is
untouched.
This commit is contained in:
1 parent
79f4d668f1
commit
d1f9ad3b25
10 files changed
+1118
-154
No files matched your search
+16
-9
@@ -1,16 +1,23 @@
|
||||
import { Alpine } from "./alpine";
|
||||
import { initLibrarySwitcher } from "./library-switcher";
|
||||
|
||||
// Rendering ownership (AGENTS.md): the series grid and its paginator are
|
||||
// rendered by templates/series.templ. This page intentionally has NO
|
||||
// TypeScript renderer — the old one here duplicated the templ markup via
|
||||
// innerHTML and re-fetched at hardcoded page 1, stomping SSR pagination
|
||||
// whenever the header's library restore fired. A library switch is a plain
|
||||
// SSR navigation so the URL stays the single source of truth for
|
||||
// library_id and page state.
|
||||
// Rendering ownership (AGENTS.md): the series grid, its paginator and
|
||||
// its sort/filter toolbar are rendered by templates/series.templ. This
|
||||
// page intentionally has NO TypeScript renderer — the old one here
|
||||
// duplicated the templ markup via innerHTML and re-fetched at hardcoded
|
||||
// page 1, stomping SSR pagination whenever the header's library restore
|
||||
// fired. A library switch is a plain SSR navigation so the URL stays
|
||||
// the single source of truth for library_id, page AND sort/filter state
|
||||
// (the switch carries the current q/author/status/sort params along).
|
||||
function navigateToSeries(libraryId: string): void {
|
||||
const param = libraryId ? `library_id=${encodeURIComponent(libraryId)}&` : "";
|
||||
window.location.href = `/series?${param}page=1`;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (libraryId) {
|
||||
params.set("library_id", libraryId);
|
||||
} else {
|
||||
params.delete("library_id");
|
||||
}
|
||||
params.set("page", "1");
|
||||
window.location.href = `/series?${params.toString()}`;
|
||||
}
|
||||
|
||||
Alpine.data("seriesPage", () => ({
|
||||
|
||||
Reference in new issue
Block a user