From ec5dda57b061607d3ec9585224e2418523c180d8 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:15:14 -0600 Subject: [PATCH] feat(books): enhance filter UI with nested groups and improvements - fix nested group ALL/ANY toggle visibility and empty-rules group handling - add FilterSummary component with color-coded field/operator/value display - add collapsible filter preview in LensView with header toggle - raise MAX_DEPTH to 5 to match backend validation - add ruleToParts helper for structured rule rendering - update filter chip styling: emerald fields, muted operators, primary values - style ALL/ANY badges with sky-blue and amber respectively - add segmented sort chip with rose accent and icon - rebuild @projectx/types package to include tag and collection in RuleField --- .../book/components/BookFilterBuilder.vue | 155 ++++++++++++++---- .../book/components/FilterChipTypeahead.vue | 127 ++++++++++++++ .../book/components/FilterFormatPicker.vue | 39 +++++ .../book/components/FilterSummary.vue | 49 ++++++ .../book/components/FilterTextTypeahead.vue | 105 ++++++++++++ client/src/features/book/lib/filter-labels.ts | 18 +- client/src/views/LensView.vue | 55 +++---- package.json | 4 +- packages/types/src/book.ts | 3 + packages/types/src/query.ts | 14 +- server/src/db/migrations/0000_baseline.sql | 2 + .../src/db/migrations/meta/0000_snapshot.json | 33 +++- server/src/db/migrations/meta/_journal.json | 2 +- server/src/db/schema/metadata.ts | 6 +- .../book/book-query-builder.service.ts | 93 ++++++++++- .../book/utils/group-rule.validator.ts | 10 +- .../src/modules/catalog/catalog.controller.ts | 21 +++ server/src/modules/catalog/catalog.service.ts | 50 +++++- server/src/modules/lens/lens.service.ts | 4 +- server/src/modules/opds/opds-book.service.ts | 2 +- 20 files changed, 692 insertions(+), 100 deletions(-) create mode 100644 client/src/features/book/components/FilterChipTypeahead.vue create mode 100644 client/src/features/book/components/FilterFormatPicker.vue create mode 100644 client/src/features/book/components/FilterSummary.vue create mode 100644 client/src/features/book/components/FilterTextTypeahead.vue diff --git a/client/src/features/book/components/BookFilterBuilder.vue b/client/src/features/book/components/BookFilterBuilder.vue index 00a9cc07a..a4b003645 100644 --- a/client/src/features/book/components/BookFilterBuilder.vue +++ b/client/src/features/book/components/BookFilterBuilder.vue @@ -3,6 +3,9 @@ import { ref, watch } from 'vue' import { Plus, Trash2 } from 'lucide-vue-next' import { FIELD_OPERATORS, RULE_FIELDS, type GroupRule, type Rule, type RuleField, type RuleOperator } from '@projectx/types' import { FIELD_LABELS, OPERATOR_LABELS } from '@/features/book/lib/filter-labels' +import FilterChipTypeahead from './FilterChipTypeahead.vue' +import FilterFormatPicker from './FilterFormatPicker.vue' +import FilterTextTypeahead from './FilterTextTypeahead.vue' const props = defineProps<{ modelValue: GroupRule | undefined @@ -13,52 +16,79 @@ const emit = defineEmits<{ 'update:modelValue': [value: GroupRule | undefined] }>() -const MAX_DEPTH = 2 +const MAX_DEPTH = 5 const NUMERIC_FIELDS: RuleField[] = ['seriesIndex', 'publishedYear', 'pageCount', 'rating'] const DATE_FIELDS: RuleField[] = ['addedAt'] const NO_VALUE_OPERATORS: RuleOperator[] = ['isEmpty', 'isNotEmpty', 'isMissing', 'isPresent', 'isUnread', 'isInProgress', 'isFinished'] const BETWEEN_OPERATORS: RuleOperator[] = ['between'] const COLLECTION_OPERATORS: RuleOperator[] = ['includesAny', 'includesAll', 'excludesAll'] +const CHIP_TYPEAHEAD_FIELDS: RuleField[] = ['author', 'genre', 'tag', 'collection'] +const TEXT_TYPEAHEAD_FIELDS: RuleField[] = ['publisher', 'series', 'language'] + +const ENDPOINT_BY_FIELD: Partial> = { + author: '/api/v1/metadata/authors', + genre: '/api/v1/metadata/genres', + tag: '/api/v1/metadata/tags', + collection: '/api/v1/metadata/collections', + publisher: '/api/v1/metadata/publishers', + series: '/api/v1/metadata/series', + language: '/api/v1/metadata/languages', +} + +type WithinLastUnit = 'days' | 'weeks' | 'months' + interface EditableRule { field: RuleField operator: RuleOperator value: string + valueChips: string[] valueTo: string + valueUnit: WithinLastUnit } -type LocalNode = { kind: 'rule'; rule: EditableRule } | { kind: 'group'; group: GroupRule } +type LocalNode = { id: number; kind: 'rule'; rule: EditableRule } | { id: number; kind: 'group'; group: GroupRule } -function makeEmptyRule(): EditableRule { - return { field: 'title', operator: 'contains', value: '', valueTo: '' } +let nodeIdCounter = 0 +function nextId() { + return ++nodeIdCounter +} + +function makeEmptyRule(): LocalNode { + return { id: nextId(), kind: 'rule', rule: { field: 'title', operator: 'contains', value: '', valueChips: [], valueTo: '', valueUnit: 'days' } } } function toEditableRule(r: Rule): EditableRule { + const usesChips = Array.isArray(r.value) return { field: r.field, operator: r.operator, - value: Array.isArray(r.value) ? (r.value as string[]).join(', ') : String(r.value ?? ''), + value: usesChips ? '' : String(r.value ?? ''), + valueChips: usesChips ? (r.value as string[]) : [], valueTo: String(r.valueTo ?? ''), + valueUnit: 'days', } } function toLocalNodes(group: GroupRule | undefined): LocalNode[] { if (!group) return [] return group.rules.map((r) => - r.type === 'rule' ? { kind: 'rule' as const, rule: toEditableRule(r) } : { kind: 'group' as const, group: r as GroupRule }, + r.type === 'rule' + ? { id: nextId(), kind: 'rule' as const, rule: toEditableRule(r) } + : { id: nextId(), kind: 'group' as const, group: r as GroupRule }, ) } -function parseValue(field: RuleField, operator: RuleOperator, raw: string): Rule['value'] { +function parseValue(field: RuleField, operator: RuleOperator, raw: string, chips: string[], unit: WithinLastUnit): Rule['value'] { if (NO_VALUE_OPERATORS.includes(operator)) return undefined - if (COLLECTION_OPERATORS.includes(operator)) { - return raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - } + if (COLLECTION_OPERATORS.includes(operator)) return chips if (NUMERIC_FIELDS.includes(field)) return raw === '' ? undefined : Number(raw) - if (DATE_FIELDS.includes(field) && operator === 'withinLast') return raw === '' ? undefined : Number(raw) + if (DATE_FIELDS.includes(field) && operator === 'withinLast') { + if (raw === '') return undefined + const n = Number(raw) + const multiplier = unit === 'weeks' ? 7 : unit === 'months' ? 30 : 1 + return n * multiplier + } return raw || undefined } @@ -77,7 +107,7 @@ watch( function isRuleComplete(r: EditableRule): boolean { if (NO_VALUE_OPERATORS.includes(r.operator)) return true - if (COLLECTION_OPERATORS.includes(r.operator)) return r.value.split(',').some((s) => s.trim() !== '') + if (COLLECTION_OPERATORS.includes(r.operator)) return r.valueChips.length > 0 return r.value.trim() !== '' } @@ -91,14 +121,14 @@ function emitUpdate() { return } const rules: (Rule | GroupRule)[] = nodes.value - .filter((n) => n.kind === 'group' || isRuleComplete(n.rule)) + .filter((n) => (n.kind === 'group' && n.group.rules.length > 0) || (n.kind === 'rule' && isRuleComplete(n.rule))) .map((n) => { if (n.kind === 'group') return n.group return { type: 'rule' as const, field: n.rule.field, operator: n.rule.operator, - value: parseValue(n.rule.field, n.rule.operator, n.rule.value), + value: parseValue(n.rule.field, n.rule.operator, n.rule.value, n.rule.valueChips, n.rule.valueUnit), valueTo: BETWEEN_OPERATORS.includes(n.rule.operator) && n.rule.valueTo !== '' ? NUMERIC_FIELDS.includes(n.rule.field) @@ -107,7 +137,15 @@ function emitUpdate() { : undefined, } }) - emit('update:modelValue', rules.length > 0 ? { type: 'group', join: join.value, rules } : undefined) + const isSubGroup = (props.depth ?? 0) > 0 + if (!isSubGroup && rules.length === 0) { + // Top-level: no complete rules means no active filter — don't send an empty group to the API. + emit('update:modelValue', undefined) + return + } + // Sub-group: always emit a group when nodes exist so the parent keeps this node alive. + // undefined is reserved for "zero nodes" and signals the parent to remove the group. + emit('update:modelValue', { type: 'group', join: join.value, rules }) } function setJoin(value: 'AND' | 'OR') { @@ -116,13 +154,14 @@ function setJoin(value: 'AND' | 'OR') { } function addRule() { - nodes.value.push({ kind: 'rule', rule: makeEmptyRule() }) + nodes.value.push(makeEmptyRule()) } function addGroup() { nodes.value.push({ + id: nextId(), kind: 'group', - group: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains' }] }, + group: { type: 'group', join: 'AND', rules: [] }, }) } @@ -139,7 +178,21 @@ function onFieldChange(index: number) { node.rule.operator = validOps[0]! } node.rule.value = '' + node.rule.valueChips = [] node.rule.valueTo = '' + node.rule.valueUnit = 'days' + emitUpdate() +} + +function onOperatorChange(index: number) { + const node = nodes.value[index] + if (node?.kind !== 'rule') return + if (COLLECTION_OPERATORS.includes(node.rule.operator)) { + node.rule.value = '' + node.rule.valueTo = '' + } else { + node.rule.valueChips = [] + } emitUpdate() } @@ -160,10 +213,6 @@ function valueInputType(field: RuleField, operator: RuleOperator): string { return 'text' } -function showValueInput(operator: RuleOperator): boolean { - return !NO_VALUE_OPERATORS.includes(operator) -} - function showValueToInput(operator: RuleOperator): boolean { return BETWEEN_OPERATORS.includes(operator) } @@ -171,8 +220,8 @@ function showValueToInput(operator: RuleOperator): boolean {