feat(series): multi-field search, sort-direction toggle, apply placement

Toolbar feedback round: the series search box now matches whatever the
main search matches for a group — series NAME, any book's AUTHOR, and
any book's TITLE inside the series (WHERE EXISTS per group-row, so
counts stay true); the separate author input is gone from the toolbar
(the author= param stays live for the JSON API and deep links). The
sort control becomes a base-key select (Date added/Name/Author/Books/
Progress/Last read) plus a chevron direction toggle that flips and
resubmits; sort/dir compose through ResolveSeriesSort (full registry
keys keep working for back-compat, unknown base keys fall to recent).
The search/apply button moved next to the search box as requested, and
default URLs stay clean (sort/dir only emitted once the user chooses).
This commit is contained in:
John O'Keefe committed 2026-10-27 19:05:23 -04:00
1 parent 14285ff314
commit 96c1228805
11 files changed
+504 -149

No files matched your search

+33 -31
View File
@@ -8,23 +8,19 @@ import (
// 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.
// every value here must have a matching base sort in
// services.seriesBaseSorts (the direction toggle composes the full
// registry key).
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"},
{"recent", "Date added"},
{"name", "Name"},
{"author", "Author"},
{"books", "Books"},
{"progress", "Progress"},
{"last_read", "Last read"},
}
// seriesStatusOptions is the progress-filter <select> (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"
/>
</div>
<input
type="text"
name="author"
value={ state.Author }
placeholder="Author"
class="input w-40"
aria-label="Filter series by author"
/>
<button type="submit" class="btn btn-secondary" aria-label="Search">
@Icon("search", "h-4 w-4")
<span>Search</span>
</button>
<select
name="status"
class="input w-auto cursor-pointer"
@@ -134,16 +126,26 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
<select
name="sort"
class="input w-auto cursor-pointer"
aria-label="Sort series"
aria-label="Sort series by"
@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>
<input type="hidden" name="dir" value={ state.Dir } x-ref="sortDir"/>
<button
type="button"
class="btn btn-secondary px-3"
aria-label="Toggle sort direction"
title="Toggle sort direction"
@click="flipSortDir()"
>
if state.Dir == "asc" {
@Icon("chevron-up", "h-4 w-4")
} else {
@Icon("chevron-down", "h-4 w-4")
}
</button>
</form>
if len(seriesList) == 0 {