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
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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