Files
bookhoard/templates/series.templ
T
John O'Keefe d1f9ad3b25 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.
2026-10-27 14:53:45 -04:00

263 lines
9.1 KiB
Templ
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package templates
import (
"fmt"
"net/url"
"strconv"
)
// 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>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Series - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="seriesPage" x-init="initSeriesPage()" class={ "theme-" + user.Theme }>
@Header(user, "/series")
@LibrarySwitcher(libData, currentLibraryID, "", nil)
<div id="series-container">
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6 flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("layers", "h-5 w-5")
</span>
<div>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Series</h1>
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Grouped by series metadata</p>
</div>
</div>
<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 {
@SeriesCard(series)
}
</div>
if totalPages > 1 {
<div id="series-pagination" class="flex justify-center items-center gap-3 mt-10">
if currentPage > 1 {
<a
href={ seriesPageURL(state, currentLibraryID, currentPage-1) }
class="btn btn-secondary"
>
@Icon("chevron-left", "h-4 w-4")
<span>Previous</span>
</a>
}
<span class="text-sm" style="color: var(--text-secondary)">
Page { fmt.Sprintf("%d", currentPage) } of { fmt.Sprintf("%d", totalPages) }
</span>
if currentPage < totalPages {
<a
href={ seriesPageURL(state, currentLibraryID, currentPage+1) }
class="btn btn-secondary"
>
<span>Next</span>
@Icon("chevron-right", "h-4 w-4")
</a>
}
</div>
}
</main>
</div>
@ErrorToast(errorMessage)
</body>
</html>
}
templ SeriesCard(series SeriesCardData) {
<a href={ "/series/detail?name=" + url.QueryEscape(series.Name) + "&library_id=" + url.QueryEscape(series.LibraryID) } class="block">
<div class="series-card card rounded-2xl overflow-hidden cursor-pointer" style="box-shadow: var(--shadow-card);">
<div class={ "stacked-covers cover-count-" + fmt.Sprintf("%d", len(series.CoverPaths)) }>
for i, cover := range series.CoverPaths {
<img
src={ cover }
class={ "cover-img cover-" + fmt.Sprintf("%d", i) }
alt={ series.Name }
loading="lazy"
/>
}
if len(series.CoverPaths) == 0 {
<div class="cover-img cover-0 flex items-center justify-center" style="background: var(--bg-primary); color: var(--text-secondary);">
@Icon("book", "h-8 w-8 opacity-60")
</div>
}
</div>
<div class="series-card-info px-3 py-2">
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">{ series.Name }</h3>
<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
}