feat(filters): the rule builder — composed engines over the field registry (faceted plan Phase 9)
- Field registry (services/filter_registry.go): ~55 typed fields — every book column, file/lifecycle facts, the three HOLDING STRUCTURES (library, collections with excluded-aware membership, series with FULL primary+alternate_info membership), and the whole user-relative class (status, progress, last read, my rating, review, notes/highlights presence, note/highlight text, time spent, pages read, collections, added by) reading the VIEWER's rows. Operators are typed constants; negation is an operator. - Composed engines (services/filter_query.go): one Go clause builder over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow shape), the field-aware suggestions, and a true-COUNT live count. Mandatory WHERE head everywhere (family lens + integrity facts). Values are always bound parameters — no user string reaches a field/operator position, ever. - Wire: the qf JSON param (validated strictly — unknown fields/operators/malformed values are 400); qf wins over flat params on hand-crafted URLs; flat deep links keep working unchanged. - Endpoints: GET /api/media-items/filter-count (live count); field-values autocomplete upgraded in place — any registry field key, qf binds the OTHER rules (symmetric under-filter suggestions, zero-match values never offered); classic six without a tree keep their static pinned contract. - UI: the drawer becomes the rule builder — the ONLY filter UI (templ markup, Alpine tree state, sanctioned island; grouped field menus, operator menus with red negations, field-aware value menus, live count, chips per rule with red negations, URL rehydration, smart serialization flat-when-simple else qf). - Tests: composed semantics (AND/OR/negation/nesting/enums/bools/ numbers/dates/empty), every user-relative field class with the my-data weld (two readers, different worlds), note-text substring pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere, 403s), suggestion-under-rules exactness, qf validation + precedence, flat-vs-tree equivalence; registry completeness unit test.
This commit is contained in:
1 parent
ebb7f4a5b0
commit
4ee24e7152
18 files changed
+3788
-376
No files matched your search
@@ -1909,3 +1909,126 @@
|
||||
background: inherit;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* ---------- Filter rule builder (filter drawer, Phase 9) ----------
|
||||
Templ owns the markup; Alpine owns the rule-tree state. Look only:
|
||||
rows indent by group depth, negated operators/chips run red. */
|
||||
.fb-connective {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
min-width: 1.6rem;
|
||||
}
|
||||
.fb-picker {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fb-picker:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
.fb-picker-active {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
.fb-picker-op {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.fb-negated-text {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
.fb-value {
|
||||
width: 9rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
.fb-menu {
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
margin-top: 0.25rem;
|
||||
width: 15rem;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.fb-menu-value {
|
||||
width: 16rem;
|
||||
left: 0;
|
||||
}
|
||||
.fb-menu-group {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 0.75rem 0.15rem;
|
||||
}
|
||||
.fb-menu-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.fb-menu-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
.fb-menu-item-active {
|
||||
color: var(--accent);
|
||||
}
|
||||
.fb-mode-btn {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.fb-mode-btn.fb-mode-active {
|
||||
background-color: var(--accent-muted);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
color: var(--accent);
|
||||
}
|
||||
.fb-mini-btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px dashed var(--border);
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fb-mini-btn:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.fb-mini-btn.fb-danger {
|
||||
border-style: solid;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
.fb-group-end {
|
||||
border-left: 2px solid var(--border);
|
||||
height: 0.5rem;
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
.fb-nested {
|
||||
border-left: 2px solid var(--border);
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
Reference in new issue
Block a user