feat(filters): one vocabulary, server-rendered — the TS mirror retires (item 3.3)
"One filter system to maintain" now holds at the presentation layer too: the field/op list the builder renders comes from services.FilterFieldsPayload(scope) — the Go registry's wire face, scope-filtered and group-ordered — rendered into the bookshelf mount as a data attribute (templates.JSONArg), ingested once by the Alpine component. The registry carries operator presentation (labels, negation, arity) in FilterOpWire alongside the grammar; per-field value-cell semantics (valueKind) derive from the existing Kind. The hardcoded TS mirror (OPS, op(), F(), TEXT_OPS/NUM_OPS/DATE_OPS, FIELD_GROUPS, its known drift surface) is deleted; filter_builder.ts now ingests the wire (ingestFilterFields — malformed payloads leave empty menus rather than client-invented rows), and the field menu's episode fields are HIDDEN on the All-Books page for free (the books scope payload excludes them — the plan's "hide podcasts from the rules on the all books page" is now structural, not enforced by a second hand-kept list). Unit tests ingest a minimal wire fixture (the payload shape is the contract, not the mirror); vitest 167 green, tsc + vp clean.
This commit is contained in:
1 parent
6b2125a45a
commit
eb68672390
5 files changed
+345
-341
No files matched your search
@@ -186,6 +186,128 @@ func (f FilterField) InScope(scope string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// FilterOpWire is one operator's presentation contract (item 3.3):
|
||||
// THE system carries both grammar and rendering in one place — the
|
||||
// frontend renders this wire, it invents nothing.
|
||||
type FilterOpWire struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Negated bool `json:"negated,omitempty"`
|
||||
// Values is the value-cell arity: "many" (one multi-value input),
|
||||
// "one", "two", or "none".
|
||||
Values string `json:"values"`
|
||||
}
|
||||
|
||||
var filterOps = map[string]FilterOpWire{
|
||||
OpContains: {Key: OpContains, Label: "contains", Values: "many"},
|
||||
OpNotContains: {Key: OpNotContains, Label: "doesn't contain", Negated: true, Values: "many"},
|
||||
OpIs: {Key: OpIs, Label: "is", Values: "many"},
|
||||
OpIsNot: {Key: OpIsNot, Label: "isn't", Negated: true, Values: "many"},
|
||||
OpEmpty: {Key: OpEmpty, Label: "is empty", Values: "none"},
|
||||
OpIsTrue: {Key: OpIsTrue, Label: "is true", Values: "none"},
|
||||
OpIsFalse: {Key: OpIsFalse, Label: "is false", Values: "none"},
|
||||
OpBetween: {Key: OpBetween, Label: "between", Values: "two"},
|
||||
OpAtLeast: {Key: OpAtLeast, Label: "at least", Values: "one"},
|
||||
OpAtMost: {Key: OpAtMost, Label: "at most", Values: "one"},
|
||||
OpWithinDays: {Key: OpWithinDays, Label: "within the last", Values: "one"},
|
||||
OpHas: {Key: OpHas, Label: "has", Values: "none"},
|
||||
OpMissing: {Key: OpMissing, Label: "missing", Values: "none"},
|
||||
OpUnrated: {Key: OpUnrated, Label: "unrated", Values: "none"},
|
||||
OpIn: {Key: OpIn, Label: "in", Values: "many"},
|
||||
OpNotIn: {Key: OpNotIn, Label: "isn't in", Negated: true, Values: "many"},
|
||||
OpInAny: {Key: OpInAny, Label: "in any", Values: "none"},
|
||||
OpInNone: {Key: OpInNone, Label: "in none", Negated: true, Values: "none"},
|
||||
}
|
||||
|
||||
// FilterFieldWire is one field's presentation entry (the registry's
|
||||
// wire face): the frontend menu is scope-filtered OFF this list.
|
||||
type FilterFieldWire struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Group string `json:"group"`
|
||||
ValueKind string `json:"valueKind"`
|
||||
Ops []FilterOpWire `json:"ops"`
|
||||
DefaultOp string `json:"defaultOp"`
|
||||
Suggest bool `json:"suggest"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// valueKinds — what the value cell renders (the registry's Kind mapped
|
||||
// to frontend-cell semantics).
|
||||
const (
|
||||
VKText = "text"
|
||||
VKNumber = "number"
|
||||
VKDays = "days"
|
||||
VKId = "id"
|
||||
VKFixed = "fixed"
|
||||
VKNone = "none"
|
||||
)
|
||||
|
||||
// valueKindFor maps the SQL-facing Kind to the cell semantics.
|
||||
func valueKindFor(kind FilterKind, key string) string {
|
||||
switch kind {
|
||||
case KindScalarFuzzy, KindArrayFuzzy, KindText:
|
||||
return VKText
|
||||
case KindEnum, KindStatus, KindLibrary, KindCollection, KindIdentifier:
|
||||
return VKId
|
||||
case KindBool:
|
||||
return VKNone
|
||||
case KindNumber, KindDate:
|
||||
if kind == KindDate {
|
||||
return VKDays
|
||||
}
|
||||
return VKNumber
|
||||
case KindPresence, KindNoteText:
|
||||
return VKNone
|
||||
}
|
||||
if key == "added_by" {
|
||||
return VKId
|
||||
}
|
||||
return VKNone
|
||||
}
|
||||
|
||||
// FilterFieldsPayload is THE builder vocabulary for one scope: group-
|
||||
// ordered wire fields (registry order), each with its ops' presentation.
|
||||
// The frontend renders this; it hardcodes nothing.
|
||||
func FilterFieldsPayload(scope string) []FilterFieldWire {
|
||||
suggestKnown := map[SuggestSource]bool{
|
||||
suggestAuthor: true, suggestNarrator: true, suggestContrib: true,
|
||||
suggestPublisher: true, suggestTags: true, suggestColumn: true,
|
||||
suggestSeries: true, suggestLibrary: true, suggestCollection: true,
|
||||
suggestAddedBy: true, suggestFeed: true,
|
||||
}
|
||||
presented := make(map[string]bool)
|
||||
out := []FilterFieldWire{}
|
||||
for _, f := range filterFields {
|
||||
if !f.InScope(scope) {
|
||||
continue
|
||||
}
|
||||
if presented[f.Key] {
|
||||
continue
|
||||
}
|
||||
presented[f.Key] = true
|
||||
ops := make([]FilterOpWire, 0, len(f.Ops))
|
||||
for _, opKey := range f.Ops {
|
||||
ops = append(ops, filterOps[opKey])
|
||||
}
|
||||
wire := FilterFieldWire{
|
||||
Key: f.Key,
|
||||
Label: f.Label,
|
||||
Group: f.Group,
|
||||
ValueKind: valueKindFor(f.Kind, f.Key),
|
||||
Ops: ops,
|
||||
DefaultOp: f.DefaultOp,
|
||||
Suggest: suggestKnown[f.Suggest],
|
||||
}
|
||||
if domain, ok := fixedDomains[f.Key]; ok {
|
||||
wire.ValueKind = VKFixed
|
||||
wire.Options = domain
|
||||
}
|
||||
out = append(out, wire)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// opsWithValue / opsWithoutValue drive validation.
|
||||
var opsNoValue = map[string]bool{
|
||||
OpEmpty: true, OpIsTrue: true, OpIsFalse: true, OpUnrated: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ package templates
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
@@ -176,6 +177,7 @@ templ BookShelf(
|
||||
innerHTML templating). Rows are a flat projection of
|
||||
the tree (depth = indent); groups nest via + group. -->
|
||||
<div x-data="filterBuilderExtra" x-cloak
|
||||
data-filter-fields={ templates.JSONArg(services.FilterFieldsPayload("books")) }
|
||||
x-effect="filtersOpen === true && ensureReadyRule()"
|
||||
>
|
||||
<template x-for="row in builderRows" :key="row.key">
|
||||
|
||||
@@ -11,6 +11,7 @@ import templruntime "github.com/a-h/templ/runtime"
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
@@ -119,7 +120,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 92, Col: 135}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 93, Col: 135}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -132,7 +133,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 94, Col: 25}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 95, Col: 25}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -195,7 +196,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 152, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -213,7 +214,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -234,7 +235,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -247,7 +248,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -260,7 +261,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -278,7 +279,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -291,7 +292,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 48}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -304,7 +305,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -323,7 +324,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 164, Col: 34}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 34}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -336,7 +337,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 164, Col: 56}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -349,7 +350,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 164, Col: 76}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -367,7 +368,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 166, Col: 34}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 34}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -380,7 +381,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 166, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -393,7 +394,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 166, Col: 67}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -406,7 +407,20 @@ func BookShelf(
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware valueLine truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak data-filter-fields=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templates.JSONArg(services.FilterFieldsPayload("books")))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 180, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware value menu: server JSON (the other\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trules bound), or the field's fixed options.\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tClicks anywhere inside the value cell never close\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tit — the opening focus-click must not race the\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toutside handler. --><div class=\"fb-menu fb-menu-value\" x-show=\"suggestionShown(row.key)\" @click.outside=\"if (!$event.target.closest('.fb-value-cell')) suggestionsOpen = Line truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -415,53 +429,53 @@ func BookShelf(
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if errorMessage != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 310, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if count > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 319, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 312, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if count > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 321, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if offset <= 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " disabled")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " disabled")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, ">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -469,43 +483,43 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 328, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 333, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 330, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 335, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if offset+limit >= count {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " disabled")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " disabled")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "><span>Next</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "><span>Next</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -513,12 +527,12 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -526,7 +540,7 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ingestFilterFields,
|
||||
isFlatable,
|
||||
isRule,
|
||||
pruneIncomplete,
|
||||
@@ -9,6 +10,37 @@ import {
|
||||
type FilterRuleState,
|
||||
} from "./filter_builder";
|
||||
|
||||
// The vocabulary now arrives from the Go registry (item 3.3); unit
|
||||
// tests ingest a minimal wire so arity/ops lookups have data — the
|
||||
// shape here mirrors services.FilterFieldsPayload, not a mirror.
|
||||
ingestFilterFields([
|
||||
{
|
||||
key: "published_year",
|
||||
label: "Published year",
|
||||
group: "Publishing",
|
||||
valueKind: "number",
|
||||
defaultOp: "at_least",
|
||||
suggest: false,
|
||||
ops: [
|
||||
{ key: "between", label: "between", arity: "two" },
|
||||
{ key: "at_least", label: "at least", arity: "one" },
|
||||
{ key: "at_most", label: "at most", arity: "one" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "cover",
|
||||
label: "Cover",
|
||||
group: "Book facts",
|
||||
valueKind: "none",
|
||||
defaultOp: "has",
|
||||
suggest: false,
|
||||
ops: [
|
||||
{ key: "has", label: "has", arity: "none" },
|
||||
{ key: "missing", label: "missing", arity: "none" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// ruleToFlatParam/isFlatable encode the PINNED deep-link wire contract:
|
||||
// these params are byte-compatible with bookmarked URLs already in the
|
||||
// wild, so assertions here are contract pins, not implementation details.
|
||||
|
||||
+105
-271
@@ -16,6 +16,81 @@ import { Alpine } from "./alpine";
|
||||
// ─── operator + field metadata (client mirror of
|
||||
// internal/services/filter_registry.go — labels drive the menus) ────
|
||||
|
||||
// ─── operator + field vocabulary (ONE source: the Go registry) ──────
|
||||
// internal/services.FilterFieldsPayload renders this page's fields,
|
||||
// scope-filtered, with every operator's label/negation/arity. The
|
||||
// frontend renders the wire and invents nothing (item 3.3) — the old
|
||||
// hardcoded client mirror (and its known drift) is retired.
|
||||
interface WireOp {
|
||||
key: string;
|
||||
label: string;
|
||||
negated?: boolean;
|
||||
arity?: OpMeta["arity"];
|
||||
}
|
||||
|
||||
interface WireField {
|
||||
key: string;
|
||||
label: string;
|
||||
group: string;
|
||||
valueKind: FieldMeta["valueKind"];
|
||||
ops: WireOp[];
|
||||
defaultOp: string;
|
||||
suggest: boolean;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
let wireOps: Record<string, OpMeta> = {};
|
||||
let wireFieldGroups: { group: string; fields: FieldMeta[] }[] = [];
|
||||
const FIELD_BY_KEY = new Map<string, FieldMeta>();
|
||||
|
||||
export function ingestFilterFields(raw: unknown): void {
|
||||
if (!Array.isArray(raw)) return;
|
||||
const fields = raw as WireField[];
|
||||
wireOps = {};
|
||||
wireFieldGroups = [];
|
||||
FIELD_BY_KEY.clear();
|
||||
for (const f of fields) {
|
||||
if (!fieldMetaOK(f)) continue;
|
||||
const fm: FieldMeta = {
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
group: f.group,
|
||||
valueKind: f.valueKind,
|
||||
ops: [],
|
||||
defaultOp: f.defaultOp,
|
||||
suggest: f.suggest === true,
|
||||
options: f.options,
|
||||
};
|
||||
let group = wireFieldGroups.find((g) => g.group === f.group);
|
||||
if (!group) {
|
||||
group = { group: f.group, fields: [] };
|
||||
wireFieldGroups.push(group);
|
||||
}
|
||||
group.fields.push(fm);
|
||||
FIELD_BY_KEY.set(f.key, fm);
|
||||
for (const o of f.ops ?? []) {
|
||||
wireOps[o.key] = {
|
||||
key: o.key,
|
||||
label: o.label,
|
||||
negated: o.negated === true,
|
||||
arity: (o.arity ?? "many") as OpMeta["arity"],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fieldMetaOK(f: WireField): boolean {
|
||||
return (
|
||||
typeof f.key === "string" &&
|
||||
f.key !== "" &&
|
||||
typeof f.label === "string" &&
|
||||
typeof f.group === "string" &&
|
||||
typeof f.valueKind === "string" &&
|
||||
Array.isArray(f.ops) &&
|
||||
typeof f.defaultOp === "string"
|
||||
);
|
||||
}
|
||||
|
||||
interface OpMeta {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -23,40 +98,6 @@ interface OpMeta {
|
||||
arity: "none" | "one" | "two" | "many";
|
||||
}
|
||||
|
||||
const op = (key: string, label: string, negated: boolean, arity: OpMeta["arity"]): OpMeta => ({
|
||||
key,
|
||||
label,
|
||||
negated,
|
||||
arity,
|
||||
});
|
||||
|
||||
// The map key DERIVES from meta.key — the wire value and the display
|
||||
// label cannot drift apart again (a mis-keyed entry threw every
|
||||
// non-self-named operator out of the allow-list: "doesn't contain" was
|
||||
// being sent literally, and the server rightly refused it).
|
||||
const OPS: Record<string, OpMeta> = Object.fromEntries(
|
||||
[
|
||||
op("contains", "contains", false, "many"),
|
||||
op("not_contains", "doesn't contain", true, "many"),
|
||||
op("is", "is", false, "many"),
|
||||
op("is_not", "isn't", true, "many"),
|
||||
op("empty", "is empty", false, "none"),
|
||||
op("is_true", "is true", false, "none"),
|
||||
op("is_false", "is false", false, "none"),
|
||||
op("between", "between", false, "two"),
|
||||
op("at_least", "at least", false, "one"),
|
||||
op("at_most", "at most", false, "one"),
|
||||
op("within_days", "within the last", false, "one"),
|
||||
op("has", "has", false, "none"),
|
||||
op("missing", "missing", false, "none"),
|
||||
op("unrated", "unrated", false, "none"),
|
||||
op("in", "in", false, "many"),
|
||||
op("not_in", "isn't in", true, "many"),
|
||||
op("in_any", "in any", false, "none"),
|
||||
op("in_none", "in none", false, "none"),
|
||||
].map((m) => [m.key, m]),
|
||||
);
|
||||
|
||||
interface FieldMeta {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -71,229 +112,6 @@ interface FieldMeta {
|
||||
defaultOp: string;
|
||||
}
|
||||
|
||||
const F = (
|
||||
key: string,
|
||||
label: string,
|
||||
group: string,
|
||||
valueKind: FieldMeta["valueKind"],
|
||||
ops: string[],
|
||||
defaultOp: string,
|
||||
suggest = false,
|
||||
options?: string[],
|
||||
): FieldMeta => ({ key, label, group, valueKind, ops, defaultOp, suggest, options });
|
||||
|
||||
const TEXT_OPS = ["contains", "not_contains", "is", "is_not", "empty"];
|
||||
const NUM_OPS = ["between", "at_least", "at_most", "empty"];
|
||||
const DATE_OPS = ["within_days", "between", "empty"];
|
||||
|
||||
const FIELD_GROUPS: { group: string; fields: FieldMeta[] }[] = [
|
||||
{
|
||||
group: "People",
|
||||
fields: [
|
||||
F("author", "Author", "People", "text", TEXT_OPS, "contains", true),
|
||||
F("narrator", "Narrator", "People", "text", TEXT_OPS, "contains", true),
|
||||
F("contributor", "Contributor", "People", "text", TEXT_OPS, "contains", true),
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Publishing",
|
||||
fields: [
|
||||
F("publisher", "Publisher", "Publishing", "text", TEXT_OPS, "contains", true),
|
||||
F("imprint", "Imprint", "Publishing", "text", TEXT_OPS, "contains", true),
|
||||
F("edition", "Edition", "Publishing", "text", TEXT_OPS, "contains", true),
|
||||
F("published_year", "Published year", "Publishing", "number", NUM_OPS, "at_least"),
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Topics",
|
||||
fields: [
|
||||
F("genre", "Genre", "Topics", "text", TEXT_OPS, "contains", true),
|
||||
F("tags", "Tags", "Topics", "text", TEXT_OPS, "contains", true),
|
||||
F("story_arc", "Story arc", "Topics", "text", TEXT_OPS, "contains", true),
|
||||
F("age_rating", "Age rating", "Topics", "text", ["is", "is_not", "empty"], "is", true),
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Book facts",
|
||||
fields: [
|
||||
F("title", "Title", "Book facts", "text", TEXT_OPS, "contains", true),
|
||||
F("subtitle", "Subtitle", "Book facts", "text", TEXT_OPS, "contains", true),
|
||||
F("description", "Description", "Book facts", "text", TEXT_OPS, "contains"),
|
||||
F("summary", "Summary", "Book facts", "none", ["has", "missing"], "has"),
|
||||
F("language", "Language", "Book facts", "text", TEXT_OPS, "contains", true),
|
||||
F("page_count", "Page count", "Book facts", "number", NUM_OPS, "at_least"),
|
||||
F("chapter_count", "Chapter count", "Book facts", "number", NUM_OPS, "at_least"),
|
||||
F("total_characters", "Total characters", "Book facts", "number", NUM_OPS, "at_least"),
|
||||
F("community_rating", "Community rating", "Book facts", "number", NUM_OPS, "at_least"),
|
||||
F("added_at", "Date added", "Book facts", "days", DATE_OPS, "within_days"),
|
||||
F("imported_at", "Date imported", "Book facts", "days", DATE_OPS, "within_days"),
|
||||
F("web_url", "Web URL", "Book facts", "none", ["has", "missing"], "has"),
|
||||
F("metadata_notes", "Metadata notes", "Book facts", "none", ["has", "missing"], "has"),
|
||||
F("isbn", "ISBN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
|
||||
F("asin", "ASIN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
|
||||
F("file_size", "File size", "Book facts", "number", NUM_OPS, "at_least"),
|
||||
F(
|
||||
"file_availability",
|
||||
"File availability",
|
||||
"Book facts",
|
||||
"fixed",
|
||||
["is", "is_not"],
|
||||
"is",
|
||||
false,
|
||||
["available", "missing"],
|
||||
),
|
||||
F("cover", "Cover", "Book facts", "none", ["has", "missing"], "has"),
|
||||
F(
|
||||
"manually_edited",
|
||||
"Manually edited",
|
||||
"Book facts",
|
||||
"none",
|
||||
["is_true", "is_false"],
|
||||
"is_true",
|
||||
),
|
||||
F("added_by", "Added by", "Book facts", "id", ["is", "has", "missing"], "has", true),
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Comics & manga",
|
||||
fields: [
|
||||
F("series", "Series", "Comics & manga", "text", TEXT_OPS, "contains", true),
|
||||
F("series_number", "Series number", "Comics & manga", "number", NUM_OPS, "at_least"),
|
||||
F("series_count", "Series count", "Comics & manga", "number", NUM_OPS, "at_least"),
|
||||
F("volume", "Volume", "Comics & manga", "number", NUM_OPS, "at_least"),
|
||||
F("format_group", "Format group", "Comics & manga", "text", ["is", "is_not"], "is", true),
|
||||
F("mime_type", "File type (mime)", "Comics & manga", "text", ["is", "is_not"], "is", true),
|
||||
F(
|
||||
"is_reflowable",
|
||||
"Reflowable",
|
||||
"Comics & manga",
|
||||
"none",
|
||||
["is_true", "is_false"],
|
||||
"is_true",
|
||||
),
|
||||
F(
|
||||
"has_fixed_layout",
|
||||
"Fixed layout",
|
||||
"Comics & manga",
|
||||
"none",
|
||||
["is_true", "is_false"],
|
||||
"is_true",
|
||||
),
|
||||
F(
|
||||
"is_black_and_white",
|
||||
"Black & white",
|
||||
"Comics & manga",
|
||||
"none",
|
||||
["is_true", "is_false"],
|
||||
"is_true",
|
||||
),
|
||||
F(
|
||||
"manga_type",
|
||||
"Manga type",
|
||||
"Comics & manga",
|
||||
"text",
|
||||
["is", "is_not", "empty"],
|
||||
"is",
|
||||
true,
|
||||
),
|
||||
F(
|
||||
"reading_direction",
|
||||
"Reading direction",
|
||||
"Comics & manga",
|
||||
"text",
|
||||
["is", "is_not", "empty"],
|
||||
"is",
|
||||
true,
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "My reading",
|
||||
fields: [
|
||||
F("status", "Reading status", "My reading", "fixed", ["is"], "is", false, [
|
||||
"unread",
|
||||
"reading",
|
||||
"finished",
|
||||
]),
|
||||
F("progress_percent", "Progress percent", "My reading", "number", NUM_OPS, "at_least"),
|
||||
F("last_read", "Last read", "My reading", "days", DATE_OPS, "within_days"),
|
||||
F(
|
||||
"my_rating",
|
||||
"My rating",
|
||||
"My reading",
|
||||
"fixed",
|
||||
["is", "at_least", "at_most", "unrated"],
|
||||
"at_least",
|
||||
false,
|
||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
|
||||
),
|
||||
F("review", "Has review", "My reading", "none", ["has", "missing"], "has"),
|
||||
F("notes", "Notes", "My reading", "none", ["has", "missing", "within_days"], "has"),
|
||||
F("highlights", "Highlights", "My reading", "none", ["has", "missing", "within_days"], "has"),
|
||||
F(
|
||||
"note_text",
|
||||
"Note text",
|
||||
"My reading",
|
||||
"text",
|
||||
["contains", "not_contains", "empty"],
|
||||
"contains",
|
||||
true,
|
||||
),
|
||||
F(
|
||||
"highlight_text",
|
||||
"Highlight text",
|
||||
"My reading",
|
||||
"text",
|
||||
["contains", "not_contains", "empty"],
|
||||
"contains",
|
||||
true,
|
||||
),
|
||||
F(
|
||||
"time_spent",
|
||||
"Time spent (minutes)",
|
||||
"My reading",
|
||||
"number",
|
||||
["between", "at_least", "at_most"],
|
||||
"at_least",
|
||||
),
|
||||
F(
|
||||
"pages_read",
|
||||
"Pages read",
|
||||
"My reading",
|
||||
"number",
|
||||
["between", "at_least", "at_most"],
|
||||
"at_least",
|
||||
),
|
||||
F(
|
||||
"collection",
|
||||
"In collection",
|
||||
"My reading",
|
||||
"id",
|
||||
["in", "not_in", "in_any", "in_none"],
|
||||
"in",
|
||||
true,
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Library",
|
||||
fields: [F("library", "Library", "Library", "id", ["is", "is_not"], "is", true)],
|
||||
},
|
||||
];
|
||||
|
||||
// fieldByKey / fieldGroupsById — lookup helpers.
|
||||
const FIELD_BY_KEY = new Map<string, FieldMeta>();
|
||||
for (const g of FIELD_GROUPS) {
|
||||
for (const f of g.fields) {
|
||||
FIELD_BY_KEY.set(f.key, f);
|
||||
}
|
||||
}
|
||||
// Grouped ops per field, precomputed in the field's op order.
|
||||
const OPS_BY_FIELD = new Map<string, OpMeta[]>();
|
||||
for (const [, f] of FIELD_BY_KEY) {
|
||||
OPS_BY_FIELD.set(f.key, f.ops.map((k) => OPS[k]).filter(Boolean));
|
||||
}
|
||||
|
||||
// Flat-expressible fields: key → the flat wire param a `contains`/`is`
|
||||
// rule serializes to (the PINNED deep-link contract — semantics
|
||||
// byte-identical to today's params).
|
||||
@@ -409,7 +227,7 @@ export function pruneIncomplete(
|
||||
if (pruned && pruned.children.length > 0) children.push(pruned);
|
||||
return;
|
||||
}
|
||||
const arity = OPS[child.op]?.arity ?? "many";
|
||||
const arity = wireOps[child.op]?.arity ?? "many";
|
||||
const vals = (child.values ?? []).map((v) => (v ?? "").trim());
|
||||
if (arity === "none") {
|
||||
children.push({ field: child.field, op: child.op, values: [] });
|
||||
@@ -530,6 +348,20 @@ interface Suggestion {
|
||||
|
||||
function filterBuilderData() {
|
||||
return {
|
||||
// Lifecycle: the Go registry renders this page's vocabulary (the
|
||||
// data-filter-fields payload); ingest once so the menus are born
|
||||
// scope-filtered and server-owned (item 3.3).
|
||||
init() {
|
||||
const raw = (this.$el as HTMLElement | null)?.dataset.filterFields;
|
||||
if (raw && !FIELD_BY_KEY.size) {
|
||||
try {
|
||||
ingestFilterFields(JSON.parse(raw));
|
||||
} catch {
|
||||
// A malformed payload leaves empty menus — the server is
|
||||
// source-of-truth; never invent rows client-side.
|
||||
}
|
||||
}
|
||||
},
|
||||
// Rule tree (source of truth; hidden wire inputs are synced after
|
||||
// every mutation).
|
||||
filterTree: { mode: "all", children: [] } as FilterGroupState,
|
||||
@@ -545,7 +377,7 @@ function filterBuilderData() {
|
||||
_countTimer: null as number | null,
|
||||
|
||||
get fieldGroups() {
|
||||
return FIELD_GROUPS;
|
||||
return wireFieldGroups;
|
||||
},
|
||||
|
||||
get builderRows(): BuilderRow[] {
|
||||
@@ -562,20 +394,20 @@ function filterBuilderData() {
|
||||
return FIELD_BY_KEY.get(key)?.label ?? "Field";
|
||||
},
|
||||
opsFor(fieldKey: string): OpMeta[] {
|
||||
return OPS_BY_FIELD.get(fieldKey) ?? [];
|
||||
return (FIELD_BY_KEY.get(fieldKey)?.ops ?? []) as unknown as OpMeta[];
|
||||
},
|
||||
opMeta(opKey: string): OpMeta | undefined {
|
||||
return OPS[opKey];
|
||||
return wireOps[opKey];
|
||||
},
|
||||
opLabelOf(rule: FilterRuleState): string {
|
||||
const m = OPS[rule.op];
|
||||
const m = wireOps[rule.op];
|
||||
return m ? m.label : rule.op;
|
||||
},
|
||||
isNegated(rule: FilterRuleState): boolean {
|
||||
return OPS[rule.op]?.negated ?? false;
|
||||
return wireOps[rule.op]?.negated ?? false;
|
||||
},
|
||||
valueArity(rule: FilterRuleState): OpMeta["arity"] {
|
||||
return OPS[rule.op]?.arity ?? "many";
|
||||
return wireOps[rule.op]?.arity ?? "many";
|
||||
},
|
||||
hasValueCell(rule: FilterRuleState): boolean {
|
||||
const field = FIELD_BY_KEY.get(rule.field);
|
||||
@@ -769,13 +601,15 @@ function filterBuilderData() {
|
||||
// groups with no matches hide.
|
||||
get visibleFieldGroups(): { group: string; fields: FieldMeta[] }[] {
|
||||
const q = this.fieldFilter.trim().toLowerCase();
|
||||
if (!q) return FIELD_GROUPS;
|
||||
return FIELD_GROUPS.map((g) => ({
|
||||
group: g.group,
|
||||
fields: g.fields.filter(
|
||||
(f) => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q),
|
||||
),
|
||||
})).filter((g) => g.fields.length > 0);
|
||||
if (!q) return wireFieldGroups;
|
||||
return wireFieldGroups
|
||||
.map((g) => ({
|
||||
group: g.group,
|
||||
fields: g.fields.filter(
|
||||
(f) => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q),
|
||||
),
|
||||
}))
|
||||
.filter((g) => g.fields.length > 0);
|
||||
},
|
||||
|
||||
// ── suggestions (field-aware, under the OTHER rules) ────────────
|
||||
|
||||
Reference in new issue
Block a user