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:
neon
2026-03-02 16:15:14 -06:00
parent ed6c7428ec
commit ec5dda57b0
20 changed files with 692 additions and 100 deletions
@@ -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>
+11 -7
View File
@@ -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 }
}
+23 -32
View File
@@ -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
View File
@@ -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",
+3
View File
@@ -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;
+9 -5
View File
@@ -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": {
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1772457969998,
"when": 1772527273328,
"tag": "0000_baseline",
"breakpoints": true
}
+3 -3
View File
@@ -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);
}
}
+43 -7
View File
@@ -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)
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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);