feat(filters+ui): per-element author/publisher filters, playlist toggles, Up Next API, reorder UI, dashboard badges

Phases 4 + 5 of the metadata overhaul (plan §4, §5) plus §3 handler
wiring.

Filters (§4.1): author_filter/publisher_filter now match per-element
against the authors[]/publishers[] arrays — a co-authored book matches
any one of its authors — with comma-joined params acting as
OR-within-facet multi-select (string_to_array + cross unnest). Legacy
single-value params unchanged on the wire; series/genre/language
filters stay on their varchar columns; narrator filter already
per-element.

Collections API (§3.3-3.5): collection detail carries viewer +
hide_finished into the listing query and exposes up_next (visible-
library-enforced reading-queue head); PUT
/api/collections/:id/items/order (owner-only, full-order rewrite);
UpdateCollectionRequest gains hide_finished/show_up_next ([]
string last-value-wins for the hidden-false-sibling checkbox pattern)
merged server-side OVER stored view_settings so imported_from_series
provenance survives edits; EditCollectionModal renders the toggles
from view_settings.

Dashboard (§5.5): sections JSON carries is_playlist/complete
(dashboard.ts is the AGENTS-documented JSON-render exception; first
paint stays templ). Fully-read playlists render '✓ Playlist complete'
instead of an empty rail; imported playlists get a 📖 Playlist badge.

Frontend: book detail shows subtitle + full authors + Explicit badge;
edit modal gains subtitle, comma-separated authors/publishers (client-
split to arrays exactly like narrators), step=any series number,
explicit checkbox; collection book cards get playlist move ↑/↓ buttons
persisting the full order via the reorder endpoint (templ partial
re-render on failure — DOM order is client-local, API order is truth).

AGENTS.md gotcha #3 amended per plan §3.7: user collections are
dashboard sections by default; the show_on_dashboard premise is
retired.

Verified: full affected integration sweep green (pins widened for
up_next/is_playlist/complete — changelog entries in
docs/contract_pins.md); unit suite green; go build + templ + vite
green.
This commit is contained in:
John O'Keefe committed 2026-10-19 15:43:59 -04:00
1 parent 1dbb0ce406
commit f94e7472b4
20 files changed
+1711 -1339

No files matched your search

+16 -4
View File
@@ -13218,14 +13218,26 @@ WHERE COALESCE(lv.is_visible, true) = true
AND mi.missing_scan_count = 0
AND ($2::uuid IS NULL
OR mi.library_id = $2::uuid)
-- Fuzzy author filter
AND ($3 = '' OR word_similarity($3, COALESCE(mi.author, '')) > 0.3)
-- Fuzzy author filter — per-element against the authors[] array (A2c
-- §4.1): a co-authored book matches ANY one of its authors, and a
-- comma-joined param is multi-select (OR within the facet).
AND ($3 = '' OR EXISTS (
SELECT 1
FROM unnest(string_to_array($3, ',')) AS f
JOIN unnest(mi.authors) AS a ON true
WHERE word_similarity(f, COALESCE(a, '')) > 0.3
))
-- Fuzzy series filter
AND ($4 = '' OR word_similarity($4, COALESCE(mi.series, '')) > 0.3)
-- Fuzzy genre filter
AND ($5 = '' OR word_similarity($5, COALESCE(mi.genre, '')) > 0.3)
-- Fuzzy publisher filter
AND ($6 = '' OR word_similarity($6, COALESCE(mi.publisher, '')) > 0.3)
-- Fuzzy publisher filter — per-element against publishers[] (A2c §4.1).
AND ($6 = '' OR EXISTS (
SELECT 1
FROM unnest(string_to_array($6, ',')) AS f
JOIN unnest(mi.publishers) AS p ON true
WHERE word_similarity(f, COALESCE(p, '')) > 0.3
))
-- Fuzzy language filter
AND ($7 = '' OR word_similarity($7, COALESCE(mi.language, '')) > 0.3)
-- Tags filter (NEW - fuzzy match against tags array)
+16 -4
View File
@@ -679,14 +679,26 @@ WHERE COALESCE(lv.is_visible, true) = true
AND mi.missing_scan_count = 0
AND (sqlc.narg('library_id')::uuid IS NULL
OR mi.library_id = sqlc.narg('library_id')::uuid)
-- Fuzzy author filter
AND (sqlc.narg('author_filter') = '' OR word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')) > 0.3)
-- Fuzzy author filter — per-element against the authors[] array (A2c
-- §4.1): a co-authored book matches ANY one of its authors, and a
-- comma-joined param is multi-select (OR within the facet).
AND (sqlc.narg('author_filter') = '' OR EXISTS (
SELECT 1
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
JOIN unnest(mi.authors) AS a ON true
WHERE word_similarity(f, COALESCE(a, '')) > 0.3
))
-- Fuzzy series filter
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
-- Fuzzy genre filter
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
-- Fuzzy publisher filter
AND (sqlc.narg('publisher_filter') = '' OR word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')) > 0.3)
-- Fuzzy publisher filter — per-element against publishers[] (A2c §4.1).
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
SELECT 1
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
JOIN unnest(mi.publishers) AS p ON true
WHERE word_similarity(f, COALESCE(p, '')) > 0.3
))
-- Fuzzy language filter
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
-- Tags filter (NEW - fuzzy match against tags array)
+33
View File
@@ -52,6 +52,12 @@ type UpdateCollectionRequest struct {
Icon string `form:"icon" json:"icon"`
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
// A2c §3.3: playlist toggles — merged into view_settings server-side
// (nil = untouched). []string because the form posts a hidden "false"
// sibling next to the checkbox; the LAST value wins. JSON clients may
// send a single-element array (or omit — untouched).
HideFinished []string `form:"hide_finished" json:"hide_finished"`
ShowUpNext []string `form:"show_up_next" json:"show_up_next"`
}
type AddBooksRequest struct {
@@ -404,6 +410,23 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// A2c §3.3: playlist toggles ride the edit form as top-level fields and
// merge into view_settings server-side (the form can't bind a map).
// The form posts a hidden "false" sibling before the checkbox, so the
// LAST value is the checkbox state.
if v := optionalFlag(req.HideFinished); v != nil {
if req.ViewSettings == nil {
req.ViewSettings = map[string]interface{}{}
}
req.ViewSettings["hide_finished"] = *v
}
if v := optionalFlag(req.ShowUpNext); v != nil {
if req.ViewSettings == nil {
req.ViewSettings = map[string]interface{}{}
}
req.ViewSettings["show_up_next"] = *v
}
collection, err := h.collectionService.UpdateCollection(
c.Request().Context(),
collectionID,
@@ -1336,3 +1359,13 @@ func (h *CollectionHandler) ReorderItems(c *echo.Context) error {
}
return c.JSON(http.StatusOK, map[string]string{"status": "reordered"})
}
// optionalFlag interprets a multi-valued form flag: last value wins
// (hidden-false sibling precedes the checkbox), absent → nil (untouched).
func optionalFlag(values []string) *bool {
if len(values) == 0 {
return nil
}
v := strings.EqualFold(values[len(values)-1], "true")
return &v
}
+13
View File
@@ -605,6 +605,19 @@ func registerFrontendRoutes(cfg *Config) {
Icon: utils.PgTextString(collection.Icon),
}
// A2c §3.3: playlist toggles from view_settings.
if len(collection.ViewSettings) > 0 {
var vs map[string]interface{}
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
if b, ok := vs["hide_finished"].(bool); ok {
colData.HideFinished = b
}
if b, ok := vs["show_up_next"].(bool); ok {
colData.ShowUpNext = b
}
}
}
// Ensure default color if empty
if colData.Color == "" {
colData.Color = "blue"
+17
View File
@@ -155,6 +155,23 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.
// UpdateCollection updates a collection
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) {
// A2c §3.3: the edit form carries only the toggles it renders — merge
// them OVER the stored view_settings so provenance keys
// (imported_from_series) and anything else on the row survive.
if len(viewSettings) > 0 {
existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err == nil && len(existing.ViewSettings) > 0 {
var stored map[string]any
if json.Unmarshal(existing.ViewSettings, &stored) == nil {
merged := stored
for k, v := range viewSettings {
merged[k] = v
}
viewSettings = merged
}
}
}
// Convert rules to JSONB ([]byte)
var rulesJSON []byte
if len(autoAssignRules) > 0 {