mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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
This commit is contained in:
@@ -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<Record<RuleField, string>> = {
|
||||
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 {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<!-- AND / OR join toggle -->
|
||||
<div v-if="nodes.length > 1" class="flex items-center gap-2 mb-0.5">
|
||||
<!-- AND / OR join toggle — always visible in sub-groups, shown at root only when 2+ nodes -->
|
||||
<div v-if="nodes.length > 1 || (depth ?? 0) > 0" class="flex items-center gap-2 mb-0.5">
|
||||
<span class="text-xs text-muted-foreground">Match</span>
|
||||
<div class="flex rounded-md border border-input overflow-hidden">
|
||||
<button
|
||||
@@ -193,31 +242,71 @@ function showValueToInput(operator: RuleOperator): boolean {
|
||||
<span class="text-xs text-muted-foreground">of the following rules</span>
|
||||
</div>
|
||||
|
||||
<template v-for="(node, index) in nodes" :key="index">
|
||||
<template v-for="(node, index) in nodes" :key="node.id">
|
||||
<!-- Rule row -->
|
||||
<div v-if="node.kind === 'rule'" class="flex flex-wrap items-center gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2.5">
|
||||
<select
|
||||
v-model="node.rule.field"
|
||||
@change="onFieldChange(index)"
|
||||
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary shrink-0"
|
||||
>
|
||||
<option v-for="field in RULE_FIELDS" :key="field" :value="field">{{ FIELD_LABELS[field] }}</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
v-model="node.rule.operator"
|
||||
@change="emitUpdate"
|
||||
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
@change="onOperatorChange(index)"
|
||||
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary shrink-0"
|
||||
>
|
||||
<option v-for="op in FIELD_OPERATORS[node.rule.field]" :key="op" :value="op">{{ OPERATOR_LABELS[op] }}</option>
|
||||
</select>
|
||||
|
||||
<template v-if="showValueInput(node.rule.operator)">
|
||||
<!-- Chip typeahead: author/genre/tag/collection always; publisher/series/language when using multi-value operators -->
|
||||
<FilterChipTypeahead
|
||||
v-if="
|
||||
(CHIP_TYPEAHEAD_FIELDS.includes(node.rule.field) || TEXT_TYPEAHEAD_FIELDS.includes(node.rule.field)) &&
|
||||
COLLECTION_OPERATORS.includes(node.rule.operator)
|
||||
"
|
||||
v-model="node.rule.valueChips"
|
||||
:endpoint="ENDPOINT_BY_FIELD[node.rule.field]!"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
<!-- Format inline toggle picker -->
|
||||
<FilterFormatPicker v-else-if="node.rule.field === 'format'" v-model="node.rule.valueChips" @update:model-value="emitUpdate" />
|
||||
<!-- withinLast: number + unit selector -->
|
||||
<template v-else-if="node.rule.operator === 'withinLast'">
|
||||
<input
|
||||
v-model="node.rule.value"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="e.g. 7"
|
||||
class="h-9 w-20 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
<select
|
||||
v-model="node.rule.valueUnit"
|
||||
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
@change="emitUpdate"
|
||||
>
|
||||
<option value="days">days</option>
|
||||
<option value="weeks">weeks</option>
|
||||
<option value="months">months</option>
|
||||
</select>
|
||||
</template>
|
||||
<!-- Text typeahead: publisher, series, language -->
|
||||
<FilterTextTypeahead
|
||||
v-else-if="TEXT_TYPEAHEAD_FIELDS.includes(node.rule.field) && !NO_VALUE_OPERATORS.includes(node.rule.operator)"
|
||||
v-model="node.rule.value"
|
||||
:endpoint="ENDPOINT_BY_FIELD[node.rule.field]!"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
<!-- Standard input: text, number, date -->
|
||||
<template v-else-if="!NO_VALUE_OPERATORS.includes(node.rule.operator)">
|
||||
<input
|
||||
v-model="node.rule.value"
|
||||
@input="emitUpdate"
|
||||
:type="valueInputType(node.rule.field, node.rule.operator)"
|
||||
:placeholder="COLLECTION_OPERATORS.includes(node.rule.operator) ? 'comma-separated' : 'value'"
|
||||
placeholder="value"
|
||||
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary min-w-32 flex-1"
|
||||
/>
|
||||
<template v-if="showValueToInput(node.rule.operator)">
|
||||
@@ -242,7 +331,7 @@ function showValueToInput(operator: RuleOperator): boolean {
|
||||
|
||||
<!-- Nested group -->
|
||||
<div v-else-if="node.kind === 'group'" class="flex items-start gap-2">
|
||||
<div class="flex-1 rounded-xl border border-primary/20 bg-primary/[0.03] p-3">
|
||||
<div class="flex-1 rounded-xl border border-primary/20 bg-primary/3 p-3">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-primary/50">Group</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
endpoint: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
|
||||
|
||||
const query = ref('')
|
||||
const suggestions = ref<string[]>([])
|
||||
const showDropdown = ref(false)
|
||||
const activeIndex = ref(-1)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let latestRequestId = 0
|
||||
|
||||
watch(query, (q) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (!q.trim()) {
|
||||
suggestions.value = []
|
||||
showDropdown.value = false
|
||||
return
|
||||
}
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const requestId = ++latestRequestId
|
||||
const res = await api(`${props.endpoint}?q=${encodeURIComponent(q)}`)
|
||||
if (requestId !== latestRequestId) return
|
||||
if (!res.ok) return
|
||||
const data: { name: string }[] = await res.json()
|
||||
suggestions.value = data.map((d) => d.name).filter((name) => !props.modelValue.includes(name))
|
||||
showDropdown.value = suggestions.value.length > 0
|
||||
activeIndex.value = -1
|
||||
}, 200)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
latestRequestId = -1
|
||||
})
|
||||
|
||||
function addChip(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || props.modelValue.includes(trimmed)) return
|
||||
emit('update:modelValue', [...props.modelValue, trimmed])
|
||||
query.value = ''
|
||||
suggestions.value = []
|
||||
showDropdown.value = false
|
||||
}
|
||||
|
||||
function removeChip(value: string) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((v) => v !== value),
|
||||
)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (activeIndex.value >= 0 && suggestions.value[activeIndex.value]) {
|
||||
addChip(suggestions.value[activeIndex.value]!)
|
||||
} else if (query.value.trim()) {
|
||||
addChip(query.value)
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, suggestions.value.length - 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, -1)
|
||||
} else if (e.key === 'Escape') {
|
||||
showDropdown.value = false
|
||||
} else if (e.key === 'Backspace' && !query.value && props.modelValue.length > 0) {
|
||||
removeChip(props.modelValue[props.modelValue.length - 1]!)
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
setTimeout(() => {
|
||||
showDropdown.value = false
|
||||
}, 150)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-wrap items-center gap-1 min-w-48 flex-1 rounded-md border border-input bg-background px-2 py-1.5 focus-within:ring-2 focus-within:ring-primary"
|
||||
>
|
||||
<span
|
||||
v-for="chip in modelValue"
|
||||
:key="chip"
|
||||
class="flex items-center gap-1 h-5 px-1.5 rounded bg-primary/15 text-primary text-xs font-medium shrink-0"
|
||||
>
|
||||
{{ chip }}
|
||||
<button type="button" @click="removeChip(chip)" class="text-primary/60 hover:text-primary leading-none">
|
||||
<X :size="10" />
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
:placeholder="modelValue.length === 0 ? (placeholder ?? 'Type to search...') : ''"
|
||||
class="flex-1 min-w-20 bg-transparent text-foreground text-sm outline-none placeholder:text-muted-foreground"
|
||||
@keydown="onKeydown"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
class="absolute top-full left-0 mt-1 z-50 w-full min-w-48 max-h-48 overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
||||
>
|
||||
<button
|
||||
v-for="(suggestion, i) in suggestions"
|
||||
:key="suggestion"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-1.5 text-sm text-foreground hover:bg-accent transition-colors"
|
||||
:class="{ 'bg-accent': i === activeIndex }"
|
||||
@mousedown.prevent="addChip(suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { BOOK_FORMATS } from '@projectx/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
|
||||
|
||||
function toggle(format: string) {
|
||||
if (props.modelValue.includes(format)) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((f) => f !== format),
|
||||
)
|
||||
} else {
|
||||
emit('update:modelValue', [...props.modelValue, format])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
v-for="format in BOOK_FORMATS"
|
||||
:key="format"
|
||||
type="button"
|
||||
class="h-7 px-2.5 rounded-md border text-xs font-medium uppercase tracking-wide transition-colors"
|
||||
:class="
|
||||
modelValue.includes(format)
|
||||
? 'border-primary bg-primary/15 text-primary'
|
||||
: 'border-input bg-background text-muted-foreground hover:text-foreground hover:border-foreground/40'
|
||||
"
|
||||
@click="toggle(format)"
|
||||
>
|
||||
{{ format }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { GroupRule, Rule } from '@projectx/types'
|
||||
import { ruleToParts } from '@/features/book/lib/filter-labels'
|
||||
|
||||
defineProps<{
|
||||
node: GroupRule
|
||||
depth?: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
class="text-[9px] font-bold uppercase tracking-widest px-1.5 py-0.5 rounded shrink-0"
|
||||
:class="node.join === 'OR' ? 'bg-amber-500/15 text-amber-500' : 'bg-sky-500/15 text-sky-500'"
|
||||
>
|
||||
{{ node.join === 'AND' ? 'ALL' : 'ANY' }}
|
||||
</span>
|
||||
|
||||
<template v-for="(rule, i) in node.rules" :key="i">
|
||||
<span v-if="i > 0" class="text-[10px] text-muted-foreground/40 font-medium select-none">
|
||||
{{ node.join === 'AND' ? '·' : '|' }}
|
||||
</span>
|
||||
|
||||
<!-- Leaf rule: color-coded field / operator / value -->
|
||||
<span
|
||||
v-if="rule.type === 'rule'"
|
||||
class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border bg-muted/40 border-border/60"
|
||||
>
|
||||
<span class="font-semibold text-foreground">{{ ruleToParts(rule as Rule).field }}</span>
|
||||
<span
|
||||
class="font-normal"
|
||||
:class="ruleToParts(rule as Rule).value === null ? 'text-primary font-semibold' : 'text-muted-foreground'"
|
||||
>{{ ruleToParts(rule as Rule).operator }}</span>
|
||||
<span v-if="ruleToParts(rule as Rule).value !== null" class="font-semibold text-primary">
|
||||
{{ ruleToParts(rule as Rule).value }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- Nested group -->
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex flex-wrap items-center gap-1 px-2 py-0.5 rounded-lg border border-border/50 bg-muted/30"
|
||||
>
|
||||
<FilterSummary :node="rule as GroupRule" :depth="(depth ?? 0) + 1" />
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
endpoint: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
const suggestions = ref<string[]>([])
|
||||
const showDropdown = ref(false)
|
||||
const activeIndex = ref(-1)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let latestRequestId = 0
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(q) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (!q.trim()) {
|
||||
suggestions.value = []
|
||||
showDropdown.value = false
|
||||
return
|
||||
}
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const requestId = ++latestRequestId
|
||||
const res = await api(`${props.endpoint}?q=${encodeURIComponent(q)}`)
|
||||
if (requestId !== latestRequestId) return
|
||||
if (!res.ok) return
|
||||
const data: { name: string }[] = await res.json()
|
||||
suggestions.value = data.map((d) => d.name)
|
||||
showDropdown.value = suggestions.value.length > 0
|
||||
activeIndex.value = -1
|
||||
}, 200)
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
latestRequestId = -1
|
||||
})
|
||||
|
||||
function select(value: string) {
|
||||
emit('update:modelValue', value)
|
||||
showDropdown.value = false
|
||||
}
|
||||
|
||||
function onInput(e: Event) {
|
||||
emit('update:modelValue', (e.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, suggestions.value.length - 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, -1)
|
||||
} else if (e.key === 'Enter' && activeIndex.value >= 0) {
|
||||
e.preventDefault()
|
||||
const s = suggestions.value[activeIndex.value]
|
||||
if (s) select(s)
|
||||
} else if (e.key === 'Escape') {
|
||||
showDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
setTimeout(() => {
|
||||
showDropdown.value = false
|
||||
}, 150)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex-1 min-w-32">
|
||||
<input
|
||||
:value="modelValue"
|
||||
type="text"
|
||||
:placeholder="placeholder ?? 'value'"
|
||||
class="h-9 w-full rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary placeholder:text-muted-foreground"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
class="absolute top-full left-0 mt-1 z-50 w-full min-w-40 max-h-48 overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
||||
>
|
||||
<button
|
||||
v-for="(suggestion, i) in suggestions"
|
||||
:key="suggestion"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-1.5 text-sm text-foreground hover:bg-accent transition-colors"
|
||||
:class="{ 'bg-accent': i === activeIndex }"
|
||||
@mousedown.prevent="select(suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ export const SORT_FIELD_LABELS: Record<SortField, string> = {
|
||||
series: 'Series',
|
||||
seriesIndex: 'Series #',
|
||||
addedAt: 'Date Added',
|
||||
updatedAt: 'Date Updated',
|
||||
publishedYear: 'Published Year',
|
||||
pageCount: 'Page Count',
|
||||
}
|
||||
@@ -20,6 +21,8 @@ export const FIELD_LABELS: Record<RuleField, string> = {
|
||||
pageCount: 'Page Count',
|
||||
author: 'Author',
|
||||
genre: 'Genre',
|
||||
tag: 'Tag',
|
||||
collection: 'Collection',
|
||||
format: 'Format',
|
||||
addedAt: 'Added Date',
|
||||
fileAvailability: 'File Availability',
|
||||
@@ -48,7 +51,7 @@ export const OPERATOR_LABELS: Record<RuleOperator, string> = {
|
||||
excludesAll: 'excludes all of',
|
||||
before: 'before',
|
||||
after: 'after',
|
||||
withinLast: 'within last (days)',
|
||||
withinLast: 'within last',
|
||||
isMissing: 'is missing',
|
||||
isPresent: 'is present',
|
||||
isUnread: 'is unread',
|
||||
@@ -56,13 +59,14 @@ export const OPERATOR_LABELS: Record<RuleOperator, string> = {
|
||||
isFinished: 'is finished',
|
||||
}
|
||||
|
||||
const NO_VALUE_OPS: RuleOperator[] = ['isEmpty', 'isNotEmpty']
|
||||
const NO_VALUE_OPS: RuleOperator[] = ['isEmpty', 'isNotEmpty', 'isMissing', 'isPresent', 'isUnread', 'isInProgress', 'isFinished']
|
||||
|
||||
export function ruleToLabel(rule: Rule): string {
|
||||
export function ruleToParts(rule: Rule): { field: string; operator: string; value: string | null } {
|
||||
const field = FIELD_LABELS[rule.field] ?? rule.field
|
||||
const op = OPERATOR_LABELS[rule.operator] ?? rule.operator
|
||||
if (NO_VALUE_OPS.includes(rule.operator)) return `${field} ${op}`
|
||||
const operator = OPERATOR_LABELS[rule.operator] ?? rule.operator
|
||||
if (NO_VALUE_OPS.includes(rule.operator)) return { field, operator, value: null }
|
||||
if (rule.operator === 'withinLast') return { field, operator, value: `${rule.value} days` }
|
||||
const val = Array.isArray(rule.value) ? (rule.value as string[]).join(', ') : String(rule.value ?? '')
|
||||
if (rule.valueTo !== undefined) return `${field} ${op} ${val} - ${rule.valueTo}`
|
||||
return `${field} ${op} ${val}`
|
||||
return { field, operator, value: rule.valueTo !== undefined ? `${val} - ${rule.valueTo}` : val }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Settings2, Trash2 } from 'lucide-vue-next'
|
||||
import { Settings2, Trash2, ChevronDown, ChevronUp, ArrowUpDown } from 'lucide-vue-next'
|
||||
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
|
||||
import BookListRow from '@/features/book/components/BookListRow.vue'
|
||||
import BookQuickView from '@/features/book/components/BookQuickView.vue'
|
||||
@@ -19,7 +19,8 @@ import { useLenses } from '@/features/lens/composables/useLenses'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useBookSelection } from '@/features/book/composables/useBookSelection'
|
||||
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { SORT_FIELD_LABELS, ruleToLabel } from '@/features/book/lib/filter-labels'
|
||||
import FilterSummary from '@/features/book/components/FilterSummary.vue'
|
||||
import { SORT_FIELD_LABELS } from '@/features/book/lib/filter-labels'
|
||||
import type { BookCard, GroupRule, SortField } from '@projectx/types'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -33,30 +34,16 @@ const lensId = computed(() => Number(route.params.id))
|
||||
const { items: books, total, loading, hasMore, load } = useLens(lensId)
|
||||
const { lenses, fetchLenses, deleteLens } = useLenses()
|
||||
|
||||
function collectRuleChips(node: GroupRule): string[] {
|
||||
const chips: string[] = []
|
||||
for (const r of node.rules) {
|
||||
if (r.type === 'rule') chips.push(ruleToLabel(r))
|
||||
else chips.push(...collectRuleChips(r as GroupRule))
|
||||
}
|
||||
return chips
|
||||
}
|
||||
|
||||
const lens = computed(() => lenses.value.find((l) => l.id === lensId.value))
|
||||
|
||||
const ruleChips = computed<string[]>(() => {
|
||||
const filter = lens.value?.filter
|
||||
if (!filter) return []
|
||||
return collectRuleChips(filter)
|
||||
})
|
||||
|
||||
const sortChip = computed(() => {
|
||||
const specs = lens.value?.defaultSort
|
||||
if (!specs?.length) return null
|
||||
return specs.map((s) => `${SORT_FIELD_LABELS[s.field as SortField] ?? s.field} ${s.dir === 'asc' ? '↑' : '↓'}`).join(', ')
|
||||
})
|
||||
|
||||
const joinLabel = computed(() => lens.value?.filter?.join ?? 'AND')
|
||||
const filterExpanded = ref(true)
|
||||
|
||||
|
||||
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleBook, rangeSelectTo, isSelected } = useBookSelection()
|
||||
|
||||
@@ -213,6 +200,15 @@ watch(loading, (isLoading) => {
|
||||
@toggle-selection="toggleSelectionMode"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
v-if="lens?.filter || sortChip"
|
||||
@click="filterExpanded = !filterExpanded"
|
||||
class="hidden md:flex items-center gap-1.5 h-8 px-3 rounded-md border border-input text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
:title="filterExpanded ? 'Hide filter' : 'Show filter'"
|
||||
>
|
||||
<component :is="filterExpanded ? ChevronUp : ChevronDown" :size="13" />
|
||||
<span>Filter</span>
|
||||
</button>
|
||||
<button
|
||||
@click="editorOpen = true; confirmDelete = false"
|
||||
class="hidden md:flex items-center gap-1.5 h-8 px-3 rounded-md border border-input text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@@ -237,23 +233,18 @@ watch(loading, (isLoading) => {
|
||||
</ViewHeader>
|
||||
|
||||
<main class="flex-1 overflow-y-auto px-4 py-4" :class="backgroundClass">
|
||||
<!-- Rule chips -->
|
||||
<div v-if="ruleChips.length > 0 || sortChip" class="flex flex-wrap items-center gap-1.5 mb-4 cursor-pointer" @click="editorOpen = true">
|
||||
<template v-if="ruleChips.length > 0">
|
||||
<span
|
||||
v-for="(chip, i) in ruleChips"
|
||||
:key="i"
|
||||
class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-full bg-primary/8 border border-primary/20 text-primary font-medium hover:bg-primary/15 transition-colors"
|
||||
>
|
||||
{{ chip }}
|
||||
</span>
|
||||
<span v-if="ruleChips.length > 1" class="text-[10px] text-muted-foreground px-1"> ({{ joinLabel }}) </span>
|
||||
</template>
|
||||
<!-- Filter summary -->
|
||||
<div v-if="filterExpanded && (lens?.filter || sortChip)" class="flex flex-wrap items-center gap-2 mb-4 cursor-pointer" @click="editorOpen = true">
|
||||
<FilterSummary v-if="lens?.filter" :node="(lens.filter as GroupRule)" />
|
||||
<span
|
||||
v-if="sortChip"
|
||||
class="text-xs px-2.5 py-1 rounded-full bg-muted border border-border text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
class="inline-flex items-center text-xs rounded-md border border-border/60 overflow-hidden"
|
||||
>
|
||||
Sort: {{ sortChip }}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-muted text-muted-foreground border-r border-border/60">
|
||||
<ArrowUpDown :size="10" class="shrink-0" />
|
||||
<span class="font-semibold">Sort</span>
|
||||
</span>
|
||||
<span class="px-2 py-0.5 bg-muted/40 text-foreground font-medium">{{ sortChip }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
+3
-1
@@ -8,7 +8,9 @@
|
||||
"build:client": "pnpm --filter client build",
|
||||
"build:server": "pnpm --filter server build",
|
||||
"lint": "pnpm -r lint",
|
||||
"test": "pnpm -r test"
|
||||
"test": "pnpm -r test",
|
||||
"db:reset": "bash scripts/db/reset.sh",
|
||||
"db:seed": "bash scripts/postman/seed.sh"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { MetadataProviderKey } from './metadata-fetch';
|
||||
|
||||
export const BOOK_FORMATS = ['epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2'] as const;
|
||||
export type BookFormat = (typeof BOOK_FORMATS)[number];
|
||||
|
||||
export type BookFileRef = {
|
||||
id: number;
|
||||
format: string | null;
|
||||
|
||||
@@ -8,6 +8,8 @@ export type RuleField =
|
||||
| 'pageCount'
|
||||
| 'author'
|
||||
| 'genre'
|
||||
| 'tag'
|
||||
| 'collection'
|
||||
| 'format'
|
||||
| 'addedAt'
|
||||
| 'fileAvailability'
|
||||
@@ -44,11 +46,13 @@ export type RuleOperator =
|
||||
|
||||
export const FIELD_OPERATORS: Record<RuleField, RuleOperator[]> = {
|
||||
title: ['contains', 'notContains', 'startsWith', 'endsWith', 'eq', 'notEq', 'isEmpty', 'isNotEmpty'],
|
||||
publisher: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'],
|
||||
language: ['eq', 'notEq', 'isEmpty', 'isNotEmpty'],
|
||||
series: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'],
|
||||
publisher: ['contains', 'notContains', 'eq', 'notEq', 'includesAny', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
language: ['eq', 'notEq', 'includesAny', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
series: ['contains', 'notContains', 'eq', 'notEq', 'includesAny', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
genre: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
tag: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
collection: ['includesAny', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
format: ['includesAny', 'excludesAll'],
|
||||
publishedYear: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
|
||||
seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
|
||||
@@ -104,9 +108,9 @@ export type GroupRule = {
|
||||
rules: (Rule | GroupRule)[]
|
||||
}
|
||||
|
||||
export type SortField = 'author' | 'title' | 'series' | 'seriesIndex' | 'addedAt' | 'publishedYear' | 'pageCount'
|
||||
export type SortField = 'author' | 'title' | 'series' | 'seriesIndex' | 'addedAt' | 'updatedAt' | 'publishedYear' | 'pageCount'
|
||||
|
||||
export const SORT_FIELDS: SortField[] = ['author', 'title', 'series', 'seriesIndex', 'addedAt', 'publishedYear', 'pageCount']
|
||||
export const SORT_FIELDS: SortField[] = ['author', 'title', 'series', 'seriesIndex', 'addedAt', 'updatedAt', 'publishedYear', 'pageCount']
|
||||
|
||||
export type SortSpec = {
|
||||
field: SortField
|
||||
|
||||
@@ -568,6 +568,8 @@ CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" US
|
||||
CREATE INDEX "authors_name_trgm_idx" ON "authors" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "bm_title_trgm_idx" ON "book_metadata" USING gin ("title" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "bm_series_trgm_idx" ON "book_metadata" USING gin ("series_name" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "bm_publisher_trgm_idx" ON "book_metadata" USING gin ("publisher" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "bm_language_idx" ON "book_metadata" USING btree ("language");--> statement-breakpoint
|
||||
CREATE INDEX "annotations_user_id_idx" ON "annotations" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "bookmarks_user_id_idx" ON "bookmarks" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "rdp_user_format_idx" ON "reader_default_preferences" USING btree ("user_id","format_group");--> statement-breakpoint
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "9b81eebf-1c40-4c73-baad-cd6109207d0f",
|
||||
"id": "d6367e5a-0f2b-4a49-9291-83caa87aa6f8",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -2426,6 +2426,37 @@
|
||||
"concurrently": false,
|
||||
"method": "gin",
|
||||
"with": {}
|
||||
},
|
||||
"bm_publisher_trgm_idx": {
|
||||
"name": "bm_publisher_trgm_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "publisher",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last",
|
||||
"opclass": "gin_trgm_ops"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "gin",
|
||||
"with": {}
|
||||
},
|
||||
"bm_language_idx": {
|
||||
"name": "bm_language_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "language",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1772457969998,
|
||||
"when": 1772527273328,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export const bookMetadata = pgTable(
|
||||
(t) => [
|
||||
index('bm_title_trgm_idx').using('gin', t.title.op('gin_trgm_ops')),
|
||||
index('bm_series_trgm_idx').using('gin', t.seriesName.op('gin_trgm_ops')),
|
||||
index('bm_publisher_trgm_idx').using('gin', t.publisher.op('gin_trgm_ops')),
|
||||
index('bm_language_idx').on(t.language),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -48,9 +50,7 @@ export const authors = pgTable(
|
||||
name: varchar('name', { length: 500 }).notNull(),
|
||||
sortName: varchar('sort_name', { length: 500 }),
|
||||
},
|
||||
(t) => [
|
||||
index('authors_name_trgm_idx').using('gin', t.name.op('gin_trgm_ops')),
|
||||
],
|
||||
(t) => [index('authors_name_trgm_idx').using('gin', t.name.op('gin_trgm_ops'))],
|
||||
);
|
||||
|
||||
export const bookAuthors = pgTable(
|
||||
|
||||
@@ -5,7 +5,20 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import type { GroupRule, Rule, SortField, SortSpec } from '@projectx/types';
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { authors, bookAuthors, bookFiles, bookGenres, bookMetadata, books, readingProgress, genres } from '../../db/schema';
|
||||
import {
|
||||
authors,
|
||||
bookAuthors,
|
||||
bookFiles,
|
||||
bookGenres,
|
||||
bookMetadata,
|
||||
books,
|
||||
bookTags,
|
||||
collectionBooks,
|
||||
collections,
|
||||
readingProgress,
|
||||
genres,
|
||||
tags,
|
||||
} from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -14,6 +27,7 @@ const SORT_FIELD_MAP: Partial<Record<SortField, AnyColumn>> = {
|
||||
series: bookMetadata.seriesName,
|
||||
seriesIndex: bookMetadata.seriesIndex,
|
||||
addedAt: books.addedAt,
|
||||
updatedAt: books.updatedAt,
|
||||
publishedYear: bookMetadata.publishedYear,
|
||||
pageCount: bookMetadata.pageCount,
|
||||
};
|
||||
@@ -77,11 +91,17 @@ export class BookQueryBuilder {
|
||||
case 'title':
|
||||
return this.textRuleToSql(bookMetadata.title, operator, value as string);
|
||||
case 'publisher':
|
||||
return this.textRuleToSql(bookMetadata.publisher, operator, value as string);
|
||||
return Array.isArray(value)
|
||||
? this.textSetRuleToSql(bookMetadata.publisher, operator, value as string[])
|
||||
: this.textRuleToSql(bookMetadata.publisher, operator, value as string);
|
||||
case 'language':
|
||||
return this.textRuleToSql(bookMetadata.language, operator, value as string);
|
||||
return Array.isArray(value)
|
||||
? this.textSetRuleToSql(bookMetadata.language, operator, value as string[])
|
||||
: this.textRuleToSql(bookMetadata.language, operator, value as string);
|
||||
case 'series':
|
||||
return this.textRuleToSql(bookMetadata.seriesName, operator, value as string);
|
||||
return Array.isArray(value)
|
||||
? this.textSetRuleToSql(bookMetadata.seriesName, operator, value as string[])
|
||||
: this.textRuleToSql(bookMetadata.seriesName, operator, value as string);
|
||||
case 'publishedYear':
|
||||
return this.numericRuleToSql(bookMetadata.publishedYear, operator, value as number, valueTo as number | undefined);
|
||||
case 'seriesIndex':
|
||||
@@ -94,6 +114,10 @@ export class BookQueryBuilder {
|
||||
return this.authorRuleToSql(operator, value as string[]);
|
||||
case 'genre':
|
||||
return this.genreRuleToSql(operator, value as string[]);
|
||||
case 'tag':
|
||||
return this.tagRuleToSql(operator, value as string[]);
|
||||
case 'collection':
|
||||
return this.collectionRuleToSql(operator, value as string[], userId);
|
||||
case 'format':
|
||||
return this.formatRuleToSql(operator, value as string[]);
|
||||
case 'addedAt':
|
||||
@@ -138,6 +162,19 @@ export class BookQueryBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
private textSetRuleToSql(col: AnyColumn, operator: string, values?: string[]): SQL {
|
||||
switch (operator) {
|
||||
case 'includesAny':
|
||||
if (!values?.length) return sql`1 = 0`;
|
||||
return inArray(col, values);
|
||||
case 'excludesAll':
|
||||
if (!values?.length) return sql`1 = 1`;
|
||||
return or(isNull(col), not(inArray(col, values)))!;
|
||||
default:
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for text set field`);
|
||||
}
|
||||
}
|
||||
|
||||
private numericRuleToSql(col: AnyColumn, operator: string, value?: number, valueTo?: number): SQL {
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
@@ -274,6 +311,54 @@ export class BookQueryBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
private tagRuleToSql(operator: string, values?: string[]): SQL {
|
||||
const sq = (whereClause?: SQL) =>
|
||||
this.db.select({ bookId: bookTags.bookId }).from(bookTags).innerJoin(tags, eq(bookTags.tagId, tags.id)).where(whereClause);
|
||||
|
||||
switch (operator) {
|
||||
case 'includesAny':
|
||||
if (!values?.length) return sql`1 = 0`;
|
||||
return inArray(books.id, sq(or(...values.map((v) => eq(tags.name, v)))));
|
||||
case 'includesAll':
|
||||
if (!values?.length) return sql`1 = 0`;
|
||||
return and(...values.map((v) => inArray(books.id, sq(eq(tags.name, v)))))!;
|
||||
case 'excludesAll':
|
||||
if (!values?.length) return sql`1 = 1`;
|
||||
return not(inArray(books.id, sq(or(...values.map((v) => eq(tags.name, v))))));
|
||||
case 'isEmpty':
|
||||
return not(inArray(books.id, sq()));
|
||||
case 'isNotEmpty':
|
||||
return inArray(books.id, sq());
|
||||
default:
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for tag field`);
|
||||
}
|
||||
}
|
||||
|
||||
private collectionRuleToSql(operator: string, values?: string[], userId?: number): SQL {
|
||||
if (userId === undefined) throw new BadRequestException('Collection filter requires an authenticated user');
|
||||
const sq = (whereClause?: SQL) =>
|
||||
this.db
|
||||
.select({ bookId: collectionBooks.bookId })
|
||||
.from(collectionBooks)
|
||||
.innerJoin(collections, and(eq(collectionBooks.collectionId, collections.id), eq(collections.userId, userId)))
|
||||
.where(whereClause);
|
||||
|
||||
switch (operator) {
|
||||
case 'includesAny':
|
||||
if (!values?.length) return sql`1 = 0`;
|
||||
return inArray(books.id, sq(or(...values.map((v) => eq(collections.name, v)))));
|
||||
case 'excludesAll':
|
||||
if (!values?.length) return sql`1 = 1`;
|
||||
return not(inArray(books.id, sq(or(...values.map((v) => eq(collections.name, v))))));
|
||||
case 'isEmpty':
|
||||
return not(inArray(books.id, sq()));
|
||||
case 'isNotEmpty':
|
||||
return inArray(books.id, sq());
|
||||
default:
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for collection field`);
|
||||
}
|
||||
}
|
||||
|
||||
private isbnRuleToSql(operator: string, value?: string): SQL {
|
||||
switch (operator) {
|
||||
case 'isEmpty':
|
||||
|
||||
@@ -15,6 +15,8 @@ const RULE_FIELDS = [
|
||||
'pageCount',
|
||||
'author',
|
||||
'genre',
|
||||
'tag',
|
||||
'collection',
|
||||
'format',
|
||||
'addedAt',
|
||||
'fileAvailability',
|
||||
@@ -52,11 +54,13 @@ const RULE_OPERATORS = [
|
||||
|
||||
const VALID_OPERATORS_BY_FIELD: Record<string, string[]> = {
|
||||
title: ['contains', 'notContains', 'startsWith', 'endsWith', 'eq', 'notEq', 'isEmpty', 'isNotEmpty'],
|
||||
publisher: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'],
|
||||
language: ['eq', 'notEq', 'isEmpty', 'isNotEmpty'],
|
||||
series: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'],
|
||||
publisher: ['contains', 'notContains', 'eq', 'notEq', 'includesAny', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
language: ['eq', 'notEq', 'includesAny', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
series: ['contains', 'notContains', 'eq', 'notEq', 'includesAny', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
genre: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
tag: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
collection: ['includesAny', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
format: ['includesAny', 'excludesAll'],
|
||||
publishedYear: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
|
||||
seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
@Controller('metadata')
|
||||
@@ -20,4 +21,24 @@ export class CatalogController {
|
||||
searchTags(@Query('q') q = '') {
|
||||
return this.catalogService.searchTags(q);
|
||||
}
|
||||
|
||||
@Get('publishers')
|
||||
searchPublishers(@Query('q') q = '') {
|
||||
return this.catalogService.searchPublishers(q);
|
||||
}
|
||||
|
||||
@Get('series')
|
||||
searchSeries(@Query('q') q = '') {
|
||||
return this.catalogService.searchSeries(q);
|
||||
}
|
||||
|
||||
@Get('languages')
|
||||
searchLanguages(@Query('q') q = '') {
|
||||
return this.catalogService.searchLanguages(q);
|
||||
}
|
||||
|
||||
@Get('collections')
|
||||
searchCollections(@CurrentUser() user: { id: number }, @Query('q') q = '') {
|
||||
return this.catalogService.searchCollections(user.id, q);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ilike } from 'drizzle-orm';
|
||||
import { and, eq, ilike, isNotNull } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { authors, genres, tags } from '../../db/schema';
|
||||
import { authors, bookMetadata, collections, genres, tags } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -12,22 +12,58 @@ type Db = NodePgDatabase<typeof schema>;
|
||||
export class CatalogService {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
searchAuthors(q: string) {
|
||||
searchAuthors(q: string): Promise<{ name: string }[]> {
|
||||
return this.searchByName(q, authors);
|
||||
}
|
||||
|
||||
searchGenres(q: string) {
|
||||
searchGenres(q: string): Promise<{ name: string }[]> {
|
||||
return this.searchByName(q, genres);
|
||||
}
|
||||
|
||||
searchTags(q: string) {
|
||||
searchTags(q: string): Promise<{ name: string }[]> {
|
||||
return this.searchByName(q, tags);
|
||||
}
|
||||
|
||||
private searchByName(q: string, table: typeof authors | typeof genres | typeof tags): Promise<{ id: number; name: string }[]> {
|
||||
searchPublishers(q: string): Promise<{ name: string }[]> {
|
||||
if (!q.trim()) return Promise.resolve([]);
|
||||
return this.db
|
||||
.select({ id: table.id, name: table.name })
|
||||
.selectDistinct({ name: bookMetadata.publisher })
|
||||
.from(bookMetadata)
|
||||
.where(and(isNotNull(bookMetadata.publisher), ilike(bookMetadata.publisher, `%${q}%`)))
|
||||
.orderBy(bookMetadata.publisher)
|
||||
.limit(15) as Promise<{ name: string }[]>;
|
||||
}
|
||||
|
||||
searchSeries(q: string): Promise<{ name: string }[]> {
|
||||
if (!q.trim()) return Promise.resolve([]);
|
||||
return this.db
|
||||
.selectDistinct({ name: bookMetadata.seriesName })
|
||||
.from(bookMetadata)
|
||||
.where(and(isNotNull(bookMetadata.seriesName), ilike(bookMetadata.seriesName, `%${q}%`)))
|
||||
.orderBy(bookMetadata.seriesName)
|
||||
.limit(15) as Promise<{ name: string }[]>;
|
||||
}
|
||||
|
||||
searchLanguages(q: string): Promise<{ name: string }[]> {
|
||||
if (!q.trim()) return Promise.resolve([]);
|
||||
return this.db
|
||||
.selectDistinct({ name: bookMetadata.language })
|
||||
.from(bookMetadata)
|
||||
.where(and(isNotNull(bookMetadata.language), ilike(bookMetadata.language, `%${q}%`)))
|
||||
.orderBy(bookMetadata.language)
|
||||
.limit(15) as Promise<{ name: string }[]>;
|
||||
}
|
||||
|
||||
searchCollections(userId: number, q: string): Promise<{ name: string }[]> {
|
||||
const baseWhere = eq(collections.userId, userId);
|
||||
const where = q.trim() ? and(baseWhere, ilike(collections.name, `%${q}%`)) : baseWhere;
|
||||
return this.db.select({ name: collections.name }).from(collections).where(where).orderBy(collections.name).limit(20);
|
||||
}
|
||||
|
||||
private searchByName(q: string, table: typeof authors | typeof genres | typeof tags): Promise<{ name: string }[]> {
|
||||
if (!q.trim()) return Promise.resolve([]);
|
||||
return this.db
|
||||
.select({ name: table.name })
|
||||
.from(table)
|
||||
.where(ilike(table.name, `%${q}%`))
|
||||
.orderBy(table.name)
|
||||
|
||||
@@ -30,7 +30,7 @@ export class LensService {
|
||||
const accessibleLibraryIds = (libs as { id: number }[]).map((l) => l.id);
|
||||
return Promise.all(
|
||||
lenses.map(async (lens) => {
|
||||
const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds });
|
||||
const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds, userId: user.id });
|
||||
const bookCount = await this.bookRepo.countWhere(where);
|
||||
return { ...lens, bookCount };
|
||||
}),
|
||||
@@ -93,7 +93,7 @@ export class LensService {
|
||||
const libs = await this.libraryService.findAll(user);
|
||||
const accessibleLibraryIds = (libs as { id: number }[]).map((l) => l.id);
|
||||
|
||||
const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds });
|
||||
const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds, userId: user.id });
|
||||
const orderBy = this.queryBuilder.buildOrderBy((lens.defaultSort as SortSpec[]) ?? []);
|
||||
const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
|
||||
where,
|
||||
|
||||
@@ -270,7 +270,7 @@ export class OpdsBookService {
|
||||
if (!lens) return { entries: [], total: 0 };
|
||||
if (!lens.isPublic && lens.userId !== userId) return { entries: [], total: 0 };
|
||||
|
||||
const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds: accessibleIds });
|
||||
const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds: accessibleIds, userId });
|
||||
const statusClause = eq(books.status, 'present');
|
||||
const combinedWhere = where ? and(where, statusClause) : statusClause;
|
||||
return this.paginatedBookQuery(combinedWhere!, sortOrder, page, size);
|
||||
|
||||
Reference in New Issue
Block a user