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

+73 -8
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
@@ -31,8 +32,9 @@ type SeriesInfo struct {
// stale URL or hand-rolled API call can never reach the SQL with an
// unhandled key.
type SeriesPageOptions struct {
Sort string // registry key, resolved by ResolveSeriesSort
Query string // series-name substring
Sort string // base toolbar key or full registry key
Dir string // asc | desc ("" = the base key's default)
Query string // multi-field: series name, author, or book title
Author string // matches when ANY book's author matches
Status string // "" | unstarted | reading | finished
ViewerUserID pgtype.UUID
@@ -65,12 +67,74 @@ var seriesStatusKeys = map[string]bool{
"finished": true,
}
// ResolveSeriesSort maps any unknown/empty sort to the default. It is
// the single source of truth backing both the SSR page and the JSON
// API — the SQL CASE cascade must contain an arm for every key here.
func ResolveSeriesSort(sort string) string {
// seriesBaseSorts is the toolbar's base sort keys (the <select>) with
// the direction each one STARTS in (the asc/desc toggle's initial
// state). Composed with ResolveSeriesSort into the full registry keys
// the SQL cascade implements.
var seriesBaseSorts = map[string]string{
"recent": "desc",
"name": "asc",
"author": "asc",
"books": "desc",
"progress": "desc",
"last_read": "desc",
}
// ResolveSeriesSort maps a sort value (either a FULL registry key like
// "name_desc" — back-compat with old URLs and the JSON API — or a BASE
// toolbar key composed with a direction) to the registry key the SQL
// cascade implements, plus the EFFECTIVE direction (for the toggle's
// arrow). Unknown values resolve to the default.
func ResolveSeriesSort(sort, dir string) (string, string) {
sort = strings.TrimSpace(strings.ToLower(sort))
dir = strings.ToLower(strings.TrimSpace(dir))
if dir != "asc" && dir != "desc" {
dir = ""
}
// Full registry key straight from the URL wins as-is.
if seriesSortKeys[sort] {
return sort
return sort, seriesDirOf(sort)
}
base := sort
if base == "" {
base = "recent"
}
defaultDir, known := seriesBaseSorts[base]
if !known {
base, defaultDir = "recent", "desc"
}
if dir == "" {
dir = defaultDir
}
if base == "recent" && dir == "desc" {
return "recent", "desc"
}
return base + "_" + dir, dir
}
// seriesDirOf derives the display direction of a full registry key.
func seriesDirOf(key string) string {
switch {
case strings.HasSuffix(key, "_asc"):
return "asc"
case strings.HasSuffix(key, "_desc"):
return "desc"
case key == "recent":
return "desc"
default:
if d, ok := seriesBaseSorts[key]; ok {
return d
}
return "desc"
}
}
// SeriesSortBase strips the direction suffix from a full registry key
// ("name_desc" → "name") for the toolbar's base-key <select>.
func SeriesSortBase(key string) string {
key = strings.TrimSuffix(strings.TrimSuffix(key, "_asc"), "_desc")
if _, ok := seriesBaseSorts[key]; ok {
return key
}
return "recent"
}
@@ -95,7 +159,8 @@ func NewSeriesService(db *database.Queries) *SeriesService {
// visibleLibraryIDs for an across-my-libraries page. Both filters compose:
// the handler validates any explicit library_id against the visible set.
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts SeriesPageOptions) ([]SeriesInfo, int, error) {
sort := nargText(ResolveSeriesSort(opts.Sort))
sortKey, _ := ResolveSeriesSort(opts.Sort, opts.Dir)
sort := nargText(sortKey)
status := nargText("")
if seriesStatusKeys[opts.Status] {
status = nargText(opts.Status)