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:
1 parent
14285ff314
commit
96c1228805
11 files changed
+504
-149
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user