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

+22 -2
View File
@@ -5304,7 +5304,19 @@ WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
AND ($4::text IS NULL OR series ILIKE '%' || $4::text || '%')
-- Multi-field search (series page): the query matches the series
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
-- a WHERE filter: a group qualifies when ANY of its rows matches,
-- so COUNT(*) keeps reporting the whole series.
AND ($4::text IS NULL OR
series ILIKE '%' || $4::text || '%'
OR author ILIKE '%' || $4::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || $4::text || '%'
))
GROUP BY library_id, series
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
AND (
@@ -5425,7 +5437,15 @@ SELECT COUNT(*)::int FROM (
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
AND ($4::text IS NULL OR series ILIKE '%' || $4::text || '%')
AND ($4::text IS NULL OR
series ILIKE '%' || $4::text || '%'
OR author ILIKE '%' || $4::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || $4::text || '%'
))
GROUP BY library_id, series
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
AND (
+22 -2
View File
@@ -4104,7 +4104,19 @@ WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
AND (sqlc.narg('series_query')::text IS NULL OR series ILIKE '%' || sqlc.narg('series_query')::text || '%')
-- Multi-field search (series page): the query matches the series
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
-- a WHERE filter: a group qualifies when ANY of its rows matches,
-- so COUNT(*) keeps reporting the whole series.
AND (sqlc.narg('series_query')::text IS NULL OR
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR author ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || sqlc.narg('series_query')::text || '%'
))
GROUP BY library_id, series
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
AND (
@@ -4152,7 +4164,15 @@ SELECT COUNT(*)::int FROM (
AND archived_at IS NULL
AND missing_scan_count = 0
AND series IS NOT NULL AND series != ''
AND (sqlc.narg('series_query')::text IS NULL OR series ILIKE '%' || sqlc.narg('series_query')::text || '%')
AND (sqlc.narg('series_query')::text IS NULL OR
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR author ILIKE '%' || sqlc.narg('series_query')::text || '%'
OR EXISTS (
SELECT 1 FROM media_items sib
WHERE sib.library_id = media_items.library_id
AND sib.series = media_items.series
AND sib.title ILIKE '%' || sqlc.narg('series_query')::text || '%'
))
GROUP BY library_id, series
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
AND (
+1
View File
@@ -68,6 +68,7 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
// unknown values resolve to defaults inside the service.
opts := services.SeriesPageOptions{
Sort: c.QueryParam("sort"),
Dir: c.QueryParam("dir"),
Query: c.QueryParam("q"),
Author: c.QueryParam("author"),
Status: c.QueryParam("status"),
+16 -5
View File
@@ -151,17 +151,28 @@ func registerFrontendRoutes(cfg *Config) {
// Series-page state (URL is the source of truth — plain GET form,
// no htmx on this page). Unknown values resolve to defaults in the
// service's registries, so hand-edited URLs stay safe.
// service's registries, so hand-edited URLs stay safe. The URL
// carries the BASE sort key + direction; opts.Sort gets the
// composed registry key (full keys win on re-resolve). With no
// sort param at all, state stays empty so default URLs remain
// clean (the toolbar treats empty as recent/desc).
rawSort := c.QueryParam("sort")
sortKey, sortDir := services.ResolveSeriesSort(rawSort, c.QueryParam("dir"))
state := templates.SeriesPageState{
Sort: c.QueryParam("sort"),
Sort: services.SeriesSortBase(sortKey),
Dir: sortDir,
Query: c.QueryParam("q"),
Author: c.QueryParam("author"),
Status: c.QueryParam("status"),
}
if rawSort == "" {
state.Sort = ""
state.Dir = ""
}
opts := services.SeriesPageOptions{
Sort: state.Sort,
Sort: sortKey,
Dir: sortDir,
Query: state.Query,
Author: state.Author,
Author: c.QueryParam("author"), // API/deep-link back-compat; the toolbar's multi-field q covers authors
Status: state.Status,
}
if viewerID, vidErr := uuid.Parse(user.ID); vidErr == nil {
+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)