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
+167
-10
@@ -3,9 +3,73 @@ package templates
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, errorMessage string) {
|
||||
// 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.
|
||||
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"},
|
||||
}
|
||||
|
||||
// seriesStatusOptions is the progress-filter <select> (mirror of the
|
||||
// status registry in services.ResolveSeriesSort's sibling map).
|
||||
var seriesStatusOptions = []struct {
|
||||
Value string
|
||||
Label string
|
||||
}{
|
||||
{"", "Any progress"},
|
||||
{"unstarted", "Unstarted"},
|
||||
{"reading", "In progress"},
|
||||
{"finished", "Finished"},
|
||||
}
|
||||
|
||||
// seriesPageURL builds a /series URL carrying the full page state so
|
||||
// pagination and clear-filters links never drop sort/filter state.
|
||||
func seriesPageURL(state SeriesPageState, libraryID string, page int) string {
|
||||
vals := url.Values{}
|
||||
if libraryID != "" {
|
||||
vals.Set("library_id", libraryID)
|
||||
}
|
||||
if state.Sort != "" {
|
||||
vals.Set("sort", state.Sort)
|
||||
}
|
||||
if state.Query != "" {
|
||||
vals.Set("q", state.Query)
|
||||
}
|
||||
if state.Author != "" {
|
||||
vals.Set("author", state.Author)
|
||||
}
|
||||
if state.Status != "" {
|
||||
vals.Set("status", state.Status)
|
||||
}
|
||||
vals.Set("page", strconv.Itoa(page))
|
||||
return "/series?" + vals.Encode()
|
||||
}
|
||||
|
||||
// hasActiveSeriesFilters reports whether the state deviates from the
|
||||
// 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")
|
||||
}
|
||||
|
||||
templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, state SeriesPageState, errorMessage string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -29,14 +93,80 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
||||
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Grouped by series metadata</p>
|
||||
</div>
|
||||
</div>
|
||||
if len(seriesList) == 0 {
|
||||
<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);">
|
||||
@Icon("layers", "h-7 w-7")
|
||||
</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>
|
||||
<form
|
||||
id="series-filter-form"
|
||||
method="get"
|
||||
action="/series"
|
||||
class="mb-6 flex flex-wrap items-center gap-3"
|
||||
>
|
||||
<input type="hidden" name="library_id" value={ currentLibraryID }/>
|
||||
<div class="relative flex-1 min-w-48">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary)">
|
||||
@Icon("search", "h-4 w-4")
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value={ state.Query }
|
||||
placeholder="Search series…"
|
||||
class="input w-full pl-9"
|
||||
aria-label="Search series by name"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="author"
|
||||
value={ state.Author }
|
||||
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()"
|
||||
>
|
||||
for _, opt := range seriesStatusOptions {
|
||||
<option value={ opt.Value } selected?={ state.Status == opt.Value }>{ opt.Label }</option>
|
||||
}
|
||||
</select>
|
||||
<select
|
||||
name="sort"
|
||||
class="input w-auto cursor-pointer"
|
||||
aria-label="Sort series"
|
||||
@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>
|
||||
</button>
|
||||
</form>
|
||||
if len(seriesList) == 0 {
|
||||
if hasActiveSeriesFilters(state) {
|
||||
<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);">
|
||||
@Icon("search", "h-7 w-7")
|
||||
</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={ seriesPageURL(SeriesPageState{}, currentLibraryID, 1) } class="btn btn-secondary">
|
||||
Clear filters
|
||||
</a>
|
||||
</div>
|
||||
} else {
|
||||
<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);">
|
||||
@Icon("layers", "h-7 w-7")
|
||||
</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>
|
||||
}
|
||||
}
|
||||
<div id="series-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
for _, series := range seriesList {
|
||||
@@ -47,7 +177,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
||||
<div id="series-pagination" class="flex justify-center items-center gap-3 mt-10">
|
||||
if currentPage > 1 {
|
||||
<a
|
||||
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1) }
|
||||
href={ seriesPageURL(state, currentLibraryID, currentPage-1) }
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
@Icon("chevron-left", "h-4 w-4")
|
||||
@@ -59,7 +189,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
||||
</span>
|
||||
if currentPage < totalPages {
|
||||
<a
|
||||
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1) }
|
||||
href={ seriesPageURL(state, currentLibraryID, currentPage+1) }
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
<span>Next</span>
|
||||
@@ -99,7 +229,34 @@ templ SeriesCard(series SeriesCardData) {
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
||||
{ fmt.Sprintf("%d", series.BookCount) } of { fmt.Sprintf("%d", series.TotalInSeries) } books
|
||||
</p>
|
||||
if series.ReadCount > 0 {
|
||||
<div class="mt-1.5 h-1 rounded-full overflow-hidden" style="background: var(--bg-primary);">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style={ fmt.Sprintf("width: %d%%; background-color: var(--accent);", readPercent(series.ReadCount, series.BookCount)) }
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-[10px] mt-0.5" style="color: var(--text-secondary)">
|
||||
{ fmt.Sprintf("%d of %d read", series.ReadCount, series.BookCount) }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
// readPercent converts (finished, total) into a clamped 0..100 int for
|
||||
// the card's progress bar.
|
||||
func readPercent(finished, total int64) int {
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
pct := int(finished * 100 / total)
|
||||
if pct < 0 {
|
||||
return 0
|
||||
}
|
||||
if pct > 100 {
|
||||
return 100
|
||||
}
|
||||
return pct
|
||||
}
|
||||
Reference in new issue
Block a user