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
@@ -149,6 +149,25 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
offset := (page - 1) * perSeriesPage
|
||||
|
||||
// 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.
|
||||
state := templates.SeriesPageState{
|
||||
Sort: c.QueryParam("sort"),
|
||||
Query: c.QueryParam("q"),
|
||||
Author: c.QueryParam("author"),
|
||||
Status: c.QueryParam("status"),
|
||||
}
|
||||
opts := services.SeriesPageOptions{
|
||||
Sort: state.Sort,
|
||||
Query: state.Query,
|
||||
Author: state.Author,
|
||||
Status: state.Status,
|
||||
}
|
||||
if viewerID, vidErr := uuid.Parse(user.ID); vidErr == nil {
|
||||
opts.ViewerUserID = uuidToPGType(viewerID)
|
||||
}
|
||||
|
||||
var seriesCards []templates.SeriesCardData
|
||||
totalPages := 1
|
||||
|
||||
@@ -162,7 +181,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
} else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] {
|
||||
errorMsg = "Library not accessible"
|
||||
} else {
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset)
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset, opts)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||
errorMsg = "Error loading series"
|
||||
@@ -183,6 +202,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: covers,
|
||||
ReadCount: s.ReadCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -194,7 +214,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, errorMsg).Render(c.Request().Context(), &buf)
|
||||
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, state, errorMsg).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user