mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(books): add lens with query engine, filter builder, and icon picker
This commit is contained in:
@@ -191,6 +191,8 @@ function navigateToResult(result: GlobalSearchResult) {
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<slot name="actions" />
|
||||
|
||||
<!-- Mobile: search icon -->
|
||||
<Button variant="ghost" size="icon" class="md:hidden h-8 w-8 text-muted-foreground hover:text-foreground" @click="mobileSearchOpen = true">
|
||||
<Search :size="15" />
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import * as Icons from 'lucide-vue-next'
|
||||
import { BookMarked, Clock, BookOpen, Settings, LogOut, KeyRound } from 'lucide-vue-next'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { Aperture, BookMarked, Clock, BookOpen, Plus, Settings, LogOut, KeyRound } from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
@@ -21,6 +22,8 @@ import { useSettingsDrawer } from '@/composables/useSettingsDrawer'
|
||||
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { useLenses } from '@/features/lens/composables/useLenses'
|
||||
import CreateLensDialog from '@/features/lens/components/CreateLensDialog.vue'
|
||||
|
||||
const { open: openSettings } = useSettingsDrawer()
|
||||
const { open: openChangePassword } = useChangePasswordDialog()
|
||||
@@ -28,10 +31,18 @@ const { user, logout } = useAuth()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { libraries, fetchLibraries } = useLibraries()
|
||||
const { lenses, fetchLenses } = useLenses()
|
||||
|
||||
const createLensOpen = ref(false)
|
||||
|
||||
const activeLibraryId = computed(() => {
|
||||
const id = route.params.id
|
||||
return id ? Number(id) : null
|
||||
return route.name === 'library' && id ? Number(id) : null
|
||||
})
|
||||
|
||||
const activeLensId = computed(() => {
|
||||
const id = route.params.id
|
||||
return route.name === 'lens' && id ? Number(id) : null
|
||||
})
|
||||
|
||||
function getLibraryIcon(name: string | null | undefined) {
|
||||
@@ -39,10 +50,20 @@ function getLibraryIcon(name: string | null | undefined) {
|
||||
return Icons.BookCopy
|
||||
}
|
||||
|
||||
onMounted(fetchLibraries)
|
||||
function getLensIcon(name: string | null | undefined) {
|
||||
if (name && name in Icons) return (Icons as Record<string, unknown>)[name]
|
||||
return Aperture
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchLibraries()
|
||||
fetchLenses()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CreateLensDialog :open="createLensOpen" @close="createLensOpen = false" />
|
||||
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
style="--sidebar-border: color-mix(in oklch, var(--primary) 18%, transparent)"
|
||||
@@ -108,6 +129,42 @@ onMounted(fetchLibraries)
|
||||
|
||||
<SidebarSeparator class="group-data-[collapsible=icon]:hidden" />
|
||||
|
||||
<!-- Lenses -->
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel class="text-[10px] uppercase tracking-widest text-sidebar-foreground/35 font-medium group-data-[collapsible=icon]:hidden">
|
||||
Lenses
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupAction tooltip="New Lens" @click="createLensOpen = true">
|
||||
<Plus />
|
||||
</SidebarGroupAction>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="lens in lenses" :key="lens.id">
|
||||
<SidebarMenuButton
|
||||
:is-active="activeLensId === lens.id"
|
||||
:tooltip="lens.name"
|
||||
class="gap-2.5"
|
||||
@click="router.push({ name: 'lens', params: { id: lens.id } })"
|
||||
>
|
||||
<component
|
||||
:is="getLensIcon(lens.icon)"
|
||||
:size="15"
|
||||
:class="activeLensId === lens.id ? 'text-sidebar-primary' : 'text-sidebar-foreground/50'"
|
||||
/>
|
||||
<span :class="activeLensId === lens.id ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'">
|
||||
{{ lens.name }}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem v-if="lenses.length === 0">
|
||||
<span class="px-2 py-1 text-xs text-sidebar-foreground/35 group-data-[collapsible=icon]:hidden">No lenses yet</span>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator class="group-data-[collapsible=icon]:hidden" />
|
||||
|
||||
<!-- Browse -->
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel class="text-[10px] uppercase tracking-widest text-sidebar-foreground/35 font-medium group-data-[collapsible=icon]:hidden">
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import * as LucideIcons from 'lucide-vue-next'
|
||||
import { ChevronDown, Search, X } from 'lucide-vue-next'
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// ── Icon list ─────────────────────────────────────────────────────────────
|
||||
|
||||
const ALL_ICONS: string[] = Object.keys(LucideIcons)
|
||||
.filter((k) => /^[A-Z]/.test(k) && !k.endsWith('Icon') && !k.startsWith('Lucide'))
|
||||
.sort()
|
||||
|
||||
function getIconComponent(name: string) {
|
||||
return (LucideIcons as Record<string, unknown>)[name]
|
||||
}
|
||||
|
||||
const selectedIconComponent = computed(() => (props.modelValue ? ((LucideIcons as Record<string, unknown>)[props.modelValue] ?? null) : null))
|
||||
|
||||
// ── Virtual grid ──────────────────────────────────────────────────────────
|
||||
|
||||
const COLS = 10
|
||||
const ROW_HEIGHT = 44
|
||||
|
||||
const query = ref('')
|
||||
|
||||
const filteredIcons = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
return q ? ALL_ICONS.filter((n) => n.toLowerCase().includes(q)) : ALL_ICONS
|
||||
})
|
||||
|
||||
interface IconRow {
|
||||
id: number
|
||||
icons: string[]
|
||||
}
|
||||
|
||||
const rows = computed<IconRow[]>(() => {
|
||||
const result: IconRow[] = []
|
||||
const list = filteredIcons.value
|
||||
for (let i = 0; i < list.length; i += COLS) {
|
||||
result.push({ id: i, icons: list.slice(i, i + COLS) })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// ── Picker state ──────────────────────────────────────────────────────────
|
||||
|
||||
const open = ref(false)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const searchRef = ref<HTMLInputElement | null>(null)
|
||||
const panelStyle = ref<Record<string, string>>({})
|
||||
|
||||
function positionPanel() {
|
||||
const rect = triggerRef.value?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const width = Math.max(rect.width, 440)
|
||||
let left = rect.left
|
||||
if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8
|
||||
if (left < 8) left = 8
|
||||
let top = rect.bottom + 4
|
||||
// Flip upward if panel would overflow viewport bottom
|
||||
if (top + 400 > window.innerHeight - 8) top = rect.top - 400 - 4
|
||||
panelStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
zIndex: '200',
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!open.value) positionPanel()
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
query.value = ''
|
||||
} else {
|
||||
nextTick(() => searchRef.value?.focus())
|
||||
}
|
||||
})
|
||||
|
||||
function select(name: string) {
|
||||
emit('update:modelValue', name === props.modelValue ? '' : name)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function clearValue() {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
// ── Click outside ─────────────────────────────────────────────────────────
|
||||
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (triggerRef.value?.contains(target) || panelRef.value?.contains(target)) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) nextTick(() => document.addEventListener('mousedown', handleOutsideClick))
|
||||
else document.removeEventListener('mousedown', handleOutsideClick)
|
||||
})
|
||||
|
||||
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Trigger button -->
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
@click="toggle"
|
||||
class="h-10 w-full flex items-center gap-2.5 rounded-lg border border-input bg-card text-sm px-3 hover:bg-muted transition-colors"
|
||||
>
|
||||
<component v-if="selectedIconComponent" :is="selectedIconComponent" :size="16" class="shrink-0 text-foreground" />
|
||||
<span v-else class="h-4 w-4 rounded bg-muted/60 shrink-0" />
|
||||
<span v-if="modelValue" class="flex-1 text-left text-foreground truncate">{{ modelValue }}</span>
|
||||
<span v-else class="flex-1 text-left text-muted-foreground/40 truncate">{{ placeholder ?? 'Choose an icon...' }}</span>
|
||||
<button
|
||||
v-if="modelValue"
|
||||
type="button"
|
||||
@click.stop="clearValue"
|
||||
class="h-5 w-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<X :size="12" />
|
||||
</button>
|
||||
<ChevronDown :size="14" class="text-muted-foreground shrink-0 transition-transform duration-200" :class="open ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
|
||||
<!-- Floating panel (teleported to avoid overflow clipping) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="icon-picker-drop">
|
||||
<div v-if="open" ref="panelRef" :style="panelStyle" class="flex flex-col rounded-xl border border-border bg-card shadow-2xl overflow-hidden">
|
||||
<!-- Search bar -->
|
||||
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-border shrink-0">
|
||||
<Search :size="13" class="text-muted-foreground shrink-0" />
|
||||
<input
|
||||
ref="searchRef"
|
||||
v-model="query"
|
||||
type="text"
|
||||
placeholder="Search icons..."
|
||||
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
|
||||
/>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-[11px] text-muted-foreground/60">{{ filteredIcons.length.toLocaleString() }}</span>
|
||||
<button v-if="query" type="button" @click="query = ''" class="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<X :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No results -->
|
||||
<div v-if="filteredIcons.length === 0" class="flex items-center justify-center py-12 text-xs text-muted-foreground">
|
||||
No icons match "{{ query }}"
|
||||
</div>
|
||||
|
||||
<!-- Virtual icon grid -->
|
||||
<RecycleScroller v-else class="overflow-y-auto px-2 py-1.5" style="height: 320px" :items="rows" :item-size="ROW_HEIGHT" key-field="id">
|
||||
<template #default="{ item }">
|
||||
<div class="flex gap-0.5">
|
||||
<button
|
||||
v-for="name in item.icons"
|
||||
:key="name"
|
||||
type="button"
|
||||
:title="name"
|
||||
:style="{ width: `calc(100% / ${COLS})`, aspectRatio: '1' }"
|
||||
class="flex items-center justify-center rounded-md transition-colors"
|
||||
:class="modelValue === name ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'"
|
||||
@click="select(name)"
|
||||
>
|
||||
<component :is="getIconComponent(name)" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.icon-picker-drop-enter-active,
|
||||
.icon-picker-drop-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
.icon-picker-drop-enter-from,
|
||||
.icon-picker-drop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { Book, BookFile } from '../composables/useBooks'
|
||||
import { bookCoverStyle } from '../composables/useBooks'
|
||||
import type { BookCard, BookFileRef } from '@projectx/types'
|
||||
import { bookCoverStyle } from '../lib/book-cover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{ book: Book }>()
|
||||
const props = defineProps<{ book: BookCard }>()
|
||||
|
||||
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
|
||||
const authorLine = computed(() => props.book.authors.join(', ') || null)
|
||||
@@ -23,7 +23,7 @@ const extraFiles = computed(() => (props.book.files.length > 1 ? props.book.file
|
||||
const coverLoaded = ref(false)
|
||||
const coverFailed = ref(false)
|
||||
|
||||
function openFile(file: BookFile) {
|
||||
function openFile(file: BookFileRef) {
|
||||
router.push({
|
||||
name: 'reader',
|
||||
params: { bookId: props.book.id, fileId: file.id },
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: GroupRule | undefined
|
||||
depth?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: GroupRule | undefined]
|
||||
}>()
|
||||
|
||||
const MAX_DEPTH = 2
|
||||
const NUMERIC_FIELDS: RuleField[] = ['seriesIndex', 'publishedYear', 'pageCount']
|
||||
const DATE_FIELDS: RuleField[] = ['addedAt']
|
||||
const NO_VALUE_OPERATORS: RuleOperator[] = ['isEmpty', 'isNotEmpty']
|
||||
const BETWEEN_OPERATORS: RuleOperator[] = ['between']
|
||||
const COLLECTION_OPERATORS: RuleOperator[] = ['includesAny', 'includesAll', 'excludesAll']
|
||||
|
||||
interface EditableRule {
|
||||
field: RuleField
|
||||
operator: RuleOperator
|
||||
value: string
|
||||
valueTo: string
|
||||
}
|
||||
|
||||
type LocalNode = { kind: 'rule'; rule: EditableRule } | { kind: 'group'; group: GroupRule }
|
||||
|
||||
function makeEmptyRule(): EditableRule {
|
||||
return { field: 'title', operator: 'contains', value: '', valueTo: '' }
|
||||
}
|
||||
|
||||
function toEditableRule(r: Rule): EditableRule {
|
||||
return {
|
||||
field: r.field,
|
||||
operator: r.operator,
|
||||
value: Array.isArray(r.value) ? (r.value as string[]).join(', ') : String(r.value ?? ''),
|
||||
valueTo: String(r.valueTo ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
)
|
||||
}
|
||||
|
||||
function parseValue(field: RuleField, operator: RuleOperator, raw: string): 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 (NUMERIC_FIELDS.includes(field)) return raw === '' ? undefined : Number(raw)
|
||||
if (DATE_FIELDS.includes(field) && operator === 'withinLast') return raw === '' ? undefined : Number(raw)
|
||||
return raw || undefined
|
||||
}
|
||||
|
||||
const nodes = ref<LocalNode[]>(toLocalNodes(props.modelValue))
|
||||
const join = ref<'AND' | 'OR'>(props.modelValue?.join ?? 'AND')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
nodes.value = toLocalNodes(val)
|
||||
join.value = val?.join ?? 'AND'
|
||||
},
|
||||
)
|
||||
|
||||
function emitUpdate() {
|
||||
if (nodes.value.length === 0) {
|
||||
emit('update:modelValue', undefined)
|
||||
return
|
||||
}
|
||||
const rules: (Rule | GroupRule)[] = nodes.value.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),
|
||||
valueTo:
|
||||
BETWEEN_OPERATORS.includes(n.rule.operator) && n.rule.valueTo !== ''
|
||||
? NUMERIC_FIELDS.includes(n.rule.field)
|
||||
? Number(n.rule.valueTo)
|
||||
: n.rule.valueTo
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
emit('update:modelValue', { type: 'group', join: join.value, rules })
|
||||
}
|
||||
|
||||
function setJoin(value: 'AND' | 'OR') {
|
||||
join.value = value
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
nodes.value.push({ kind: 'rule', rule: makeEmptyRule() })
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function addGroup() {
|
||||
nodes.value.push({
|
||||
kind: 'group',
|
||||
group: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains' }] },
|
||||
})
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function removeNode(index: number) {
|
||||
nodes.value.splice(index, 1)
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function onFieldChange(index: number) {
|
||||
const node = nodes.value[index]
|
||||
if (node?.kind !== 'rule') return
|
||||
const validOps = FIELD_OPERATORS[node.rule.field]
|
||||
if (!validOps.includes(node.rule.operator)) {
|
||||
node.rule.operator = validOps[0]!
|
||||
}
|
||||
node.rule.value = ''
|
||||
node.rule.valueTo = ''
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function onSubGroupUpdate(index: number, val: GroupRule | undefined) {
|
||||
if (val === undefined) {
|
||||
nodes.value.splice(index, 1)
|
||||
} else {
|
||||
const node = nodes.value[index]
|
||||
if (node?.kind === 'group') node.group = val
|
||||
}
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function valueInputType(field: RuleField, operator: RuleOperator): string {
|
||||
if (NO_VALUE_OPERATORS.includes(operator)) return 'none'
|
||||
if (DATE_FIELDS.includes(field)) return operator === 'withinLast' ? 'number' : 'date'
|
||||
if (NUMERIC_FIELDS.includes(field)) return 'number'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
function showValueInput(operator: RuleOperator): boolean {
|
||||
return !NO_VALUE_OPERATORS.includes(operator)
|
||||
}
|
||||
|
||||
function showValueToInput(operator: RuleOperator): boolean {
|
||||
return BETWEEN_OPERATORS.includes(operator)
|
||||
}
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<span class="text-xs text-muted-foreground">Match</span>
|
||||
<div class="flex rounded-md border border-input overflow-hidden">
|
||||
<button
|
||||
@click="setJoin('AND')"
|
||||
class="px-3 py-1 text-xs font-semibold transition-colors"
|
||||
:class="join === 'AND' ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground hover:bg-muted/80'"
|
||||
>
|
||||
ALL
|
||||
</button>
|
||||
<button
|
||||
@click="setJoin('OR')"
|
||||
class="px-3 py-1 text-xs font-semibold border-l border-input transition-colors"
|
||||
:class="join === 'OR' ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground hover:bg-muted/80'"
|
||||
>
|
||||
ANY
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">of the following rules</span>
|
||||
</div>
|
||||
|
||||
<template v-for="(node, index) in nodes" :key="index">
|
||||
<!-- 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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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)">
|
||||
<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'"
|
||||
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)">
|
||||
<span class="text-xs text-muted-foreground">to</span>
|
||||
<input
|
||||
v-model="node.rule.valueTo"
|
||||
@input="emitUpdate"
|
||||
:type="valueInputType(node.rule.field, node.rule.operator)"
|
||||
placeholder="max"
|
||||
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 w-24"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<button
|
||||
@click="removeNode(index)"
|
||||
class="ml-auto h-8 w-8 flex items-center justify-center rounded-md text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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 items-center justify-between mb-3">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-primary/50">Group</span>
|
||||
</div>
|
||||
<BookFilterBuilder :model-value="node.group" :depth="(depth ?? 0) + 1" @update:model-value="onSubGroupUpdate(index, $event)" />
|
||||
</div>
|
||||
<button
|
||||
@click="removeNode(index)"
|
||||
class="h-8 w-8 flex items-center justify-center rounded-md text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-colors shrink-0 mt-1"
|
||||
>
|
||||
<Trash2 :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add buttons -->
|
||||
<div class="flex items-center gap-2 pt-0.5">
|
||||
<button
|
||||
@click="addRule"
|
||||
class="flex items-center gap-1.5 h-9 px-4 rounded-lg border border-dashed border-input text-sm text-muted-foreground hover:text-foreground hover:border-foreground hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<Plus :size="13" />
|
||||
Add rule
|
||||
</button>
|
||||
<button
|
||||
v-if="(depth ?? 0) < MAX_DEPTH"
|
||||
@click="addGroup"
|
||||
class="flex items-center gap-1.5 h-9 px-4 rounded-lg border border-dashed border-primary/30 text-sm text-primary/50 hover:text-primary hover:border-primary hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<Plus :size="13" />
|
||||
Add group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { computed, reactive, ref, watch, type Ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { BookCard, BookQuery, BooksPage, GroupRule, SortSpec } from '@projectx/types'
|
||||
|
||||
export type { BookCard }
|
||||
|
||||
export function useBookQuery(libraryId: Ref<number | null>) {
|
||||
const items = ref<BookCard[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const filter = ref<GroupRule | undefined>(undefined)
|
||||
const sort = ref<SortSpec[]>([{ field: 'title', dir: 'asc' }])
|
||||
const pagination = reactive({ page: 0, size: 50 })
|
||||
|
||||
const hasMore = computed(() => items.value.length < total.value)
|
||||
|
||||
async function load(reset = false) {
|
||||
if (loading.value || libraryId.value === null) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
if (reset) {
|
||||
pagination.page = 0
|
||||
items.value = []
|
||||
}
|
||||
|
||||
try {
|
||||
const body: BookQuery = {
|
||||
filter: filter.value,
|
||||
sort: sort.value,
|
||||
pagination: { page: pagination.page, size: pagination.size },
|
||||
}
|
||||
|
||||
const res = await api(`/api/libraries/${libraryId.value}/books`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
const page: BooksPage = await res.json()
|
||||
|
||||
if (pagination.page === 0) {
|
||||
items.value = page.items
|
||||
} else {
|
||||
items.value.push(...page.items)
|
||||
}
|
||||
total.value = page.total
|
||||
pagination.page++
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load books'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
items.value = []
|
||||
total.value = 0
|
||||
pagination.page = 0
|
||||
}
|
||||
|
||||
watch(libraryId, (newId, oldId) => {
|
||||
if (newId !== oldId) load(true)
|
||||
})
|
||||
|
||||
return { items, total, loading, error, filter, sort, pagination, hasMore, load, clear }
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { bookCoverStyle } from '../lib/book-cover'
|
||||
|
||||
export { bookCoverStyle }
|
||||
|
||||
export interface BookFile {
|
||||
id: number
|
||||
@@ -24,19 +27,6 @@ interface BooksPage {
|
||||
size: number
|
||||
}
|
||||
|
||||
// Deterministic per-book cover hue from title — OKLch, always dark and readable.
|
||||
export function bookCoverStyle(seed: string): { background: string; color: string } {
|
||||
let hash = 0
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = seed.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
const hue = Math.abs(hash) % 360
|
||||
return {
|
||||
background: `oklch(0.22 0.07 ${hue})`,
|
||||
color: `oklch(0.92 0.03 ${hue})`,
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function useBooks(libraryId: Ref<number | null>) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export function bookCoverStyle(seed: string): { background: string; color: string } {
|
||||
let hash = 0
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = seed.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
const hue = Math.abs(hash) % 360
|
||||
return {
|
||||
background: `oklch(0.22 0.07 ${hue})`,
|
||||
color: `oklch(0.92 0.03 ${hue})`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Rule, RuleField, RuleOperator } from '@projectx/types'
|
||||
|
||||
export const FIELD_LABELS: Record<RuleField, string> = {
|
||||
title: 'Title',
|
||||
publisher: 'Publisher',
|
||||
language: 'Language',
|
||||
series: 'Series',
|
||||
seriesIndex: 'Series Index',
|
||||
publishedYear: 'Published Year',
|
||||
pageCount: 'Page Count',
|
||||
author: 'Author',
|
||||
tag: 'Tag',
|
||||
format: 'Format',
|
||||
addedAt: 'Added Date',
|
||||
}
|
||||
|
||||
export const OPERATOR_LABELS: Record<RuleOperator, string> = {
|
||||
contains: 'contains',
|
||||
notContains: 'does not contain',
|
||||
startsWith: 'starts with',
|
||||
endsWith: 'ends with',
|
||||
eq: 'is',
|
||||
notEq: 'is not',
|
||||
isEmpty: 'is empty',
|
||||
isNotEmpty: 'is not empty',
|
||||
gt: 'greater than',
|
||||
gte: 'at least',
|
||||
lt: 'less than',
|
||||
lte: 'at most',
|
||||
between: 'between',
|
||||
includesAny: 'includes any of',
|
||||
includesAll: 'includes all of',
|
||||
excludesAll: 'excludes all of',
|
||||
before: 'before',
|
||||
after: 'after',
|
||||
withinLast: 'within last (days)',
|
||||
}
|
||||
|
||||
const NO_VALUE_OPS: RuleOperator[] = ['isEmpty', 'isNotEmpty']
|
||||
|
||||
export function ruleToLabel(rule: Rule): string {
|
||||
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 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}`
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { useLenses } from '@/features/lens/composables/useLenses'
|
||||
|
||||
defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const router = useRouter()
|
||||
const { createLens } = useLenses()
|
||||
|
||||
const name = ref('')
|
||||
const icon = ref('')
|
||||
const isPublic = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function submit() {
|
||||
if (!name.value.trim()) return
|
||||
saving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const lens = await createLens({
|
||||
name: name.value.trim(),
|
||||
icon: icon.value.trim() || undefined,
|
||||
defaultSort: [],
|
||||
isPublic: isPublic.value,
|
||||
})
|
||||
name.value = ''
|
||||
icon.value = ''
|
||||
isPublic.value = false
|
||||
emit('close')
|
||||
router.push({ name: 'lens', params: { id: lens.id } })
|
||||
} catch {
|
||||
error.value = 'Failed to create lens'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="emit('close')" />
|
||||
<div class="relative z-10 w-full max-w-md mx-4 bg-card border border-border rounded-xl shadow-2xl p-6">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-base font-semibold text-foreground">New Lens</h2>
|
||||
<button @click="emit('close')" class="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-sm font-medium text-foreground">Name</label>
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
placeholder="e.g. Unread Sci-Fi"
|
||||
autofocus
|
||||
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-3 focus:outline-none focus:ring-2 focus:ring-primary placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-sm font-medium text-foreground"> Icon <span class="text-muted-foreground font-normal">(optional)</span> </label>
|
||||
<input
|
||||
v-model="icon"
|
||||
type="text"
|
||||
placeholder="e.g. Telescope"
|
||||
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-3 focus:outline-none focus:ring-2 focus:ring-primary placeholder:text-muted-foreground"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">Any Lucide icon name</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2.5 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="isPublic" class="h-4 w-4 rounded border border-input accent-primary" />
|
||||
<span class="text-sm text-foreground">Visible to all users</span>
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('close')"
|
||||
class="h-9 px-4 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!name.trim() || saving"
|
||||
class="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ saving ? 'Creating...' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { X, Zap } from 'lucide-vue-next'
|
||||
import { api } from '@/lib/api'
|
||||
import type { GroupRule, Rule, SortField, SortSpec } from '@projectx/types'
|
||||
import type { Lens } from '@/features/lens/composables/useLenses'
|
||||
import { useLenses } from '@/features/lens/composables/useLenses'
|
||||
import BookFilterBuilder from '@/features/book/components/BookFilterBuilder.vue'
|
||||
import IconPicker from '@/components/IconPicker.vue'
|
||||
|
||||
const SORT_FIELD_LABELS: Record<SortField, string> = {
|
||||
title: 'Title',
|
||||
addedAt: 'Date Added',
|
||||
publishedYear: 'Published Year',
|
||||
pageCount: 'Page Count',
|
||||
seriesIndex: 'Series Index',
|
||||
}
|
||||
|
||||
const TEMPLATES: { label: string; build: () => GroupRule }[] = [
|
||||
{
|
||||
label: 'Has Series',
|
||||
build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'series', operator: 'isNotEmpty' } as Rule] }),
|
||||
},
|
||||
{
|
||||
label: 'Added Recently',
|
||||
build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'addedAt', operator: 'withinLast', value: 30 } as Rule] }),
|
||||
},
|
||||
{
|
||||
label: 'PDF Only',
|
||||
build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'format', operator: 'includesAny', value: ['pdf'] } as Rule] }),
|
||||
},
|
||||
{
|
||||
label: 'No Tags',
|
||||
build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'tag', operator: 'isEmpty' } as Rule] }),
|
||||
},
|
||||
{
|
||||
label: 'EPUB Only',
|
||||
build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'format', operator: 'includesAny', value: ['epub'] } as Rule] }),
|
||||
},
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
lens: Lens | null | undefined
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { updateLens } = useLenses()
|
||||
|
||||
const draftName = ref('')
|
||||
const draftIcon = ref('')
|
||||
const draftFilter = ref<GroupRule | undefined>(undefined)
|
||||
const draftSort = ref<SortSpec[]>([])
|
||||
const saving = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
|
||||
const previewCount = ref<number | null>(null)
|
||||
const previewLoading = ref(false)
|
||||
let previewTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen && props.lens) {
|
||||
draftName.value = props.lens.name
|
||||
draftIcon.value = props.lens.icon ?? ''
|
||||
draftFilter.value = props.lens.filter ?? undefined
|
||||
draftSort.value = props.lens.defaultSort ? [...props.lens.defaultSort] : []
|
||||
schedulePreview()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function schedulePreview() {
|
||||
if (previewTimer) clearTimeout(previewTimer)
|
||||
previewTimer = setTimeout(fetchPreview, 450)
|
||||
}
|
||||
|
||||
watch([draftFilter, draftSort], schedulePreview, { deep: true })
|
||||
|
||||
async function fetchPreview() {
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
sort: draftSort.value,
|
||||
pagination: { page: 0, size: 1 },
|
||||
}
|
||||
if (draftFilter.value) body.filter = draftFilter.value
|
||||
const res = await api('/api/books/query', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
previewCount.value = res.ok ? (await res.json()).total : null
|
||||
} catch {
|
||||
previewCount.value = null
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const sortField = computed({
|
||||
get: () => draftSort.value[0]?.field ?? 'title',
|
||||
set: (field: SortField) => {
|
||||
draftSort.value = [{ field, dir: draftSort.value[0]?.dir ?? 'asc' }]
|
||||
},
|
||||
})
|
||||
|
||||
const sortDir = computed(() => draftSort.value[0]?.dir ?? 'asc')
|
||||
|
||||
function setSortDir(dir: 'asc' | 'desc') {
|
||||
draftSort.value = [{ field: sortField.value, dir }]
|
||||
}
|
||||
|
||||
function applyTemplate(t: (typeof TEMPLATES)[number]) {
|
||||
draftFilter.value = t.build()
|
||||
}
|
||||
|
||||
function initFilter() {
|
||||
draftFilter.value = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains' }] }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!props.lens) return
|
||||
saving.value = true
|
||||
saveError.value = null
|
||||
try {
|
||||
await updateLens(props.lens.id, {
|
||||
name: draftName.value.trim(),
|
||||
icon: draftIcon.value.trim(),
|
||||
filter: draftFilter.value,
|
||||
defaultSort: draftSort.value,
|
||||
})
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch {
|
||||
saveError.value = 'Failed to save'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="lens-fade">
|
||||
<div v-if="open" class="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm" @click="emit('close')" />
|
||||
</Transition>
|
||||
|
||||
<Transition name="lens-slide">
|
||||
<div v-if="open" class="fixed inset-y-0 right-0 z-50 flex flex-col w-full sm:w-[750px] bg-card border-l border-border shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start gap-4 px-6 py-5 border-b border-border shrink-0">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-base font-semibold text-foreground leading-tight truncate">
|
||||
{{ draftName || 'Untitled Lens' }}
|
||||
</h2>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">Configure lens settings</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 mt-0.5">
|
||||
<span
|
||||
v-if="previewCount !== null || previewLoading"
|
||||
class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-full border font-medium transition-all"
|
||||
:class="previewLoading ? 'border-border text-muted-foreground' : 'border-primary/30 bg-primary/8 text-primary'"
|
||||
>
|
||||
<span v-if="previewLoading" class="animate-pulse">counting...</span>
|
||||
<template v-else>{{ previewCount?.toLocaleString() }} books</template>
|
||||
</span>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="h-8 w-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-6 flex flex-col gap-5">
|
||||
<!-- Identity card -->
|
||||
<div class="rounded-xl border border-border bg-background p-5 flex flex-col gap-4">
|
||||
<h3 class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">Identity</h3>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1 flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-foreground/70">Name</label>
|
||||
<input
|
||||
v-model="draftName"
|
||||
type="text"
|
||||
placeholder="e.g. Unread Sci-Fi"
|
||||
class="h-10 rounded-lg border border-input bg-card text-foreground text-sm px-3 focus:outline-none focus:ring-2 focus:ring-primary placeholder:text-muted-foreground/40"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-foreground/70">Icon</label>
|
||||
<IconPicker v-model="draftIcon" placeholder="Choose an icon..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters card -->
|
||||
<div class="rounded-xl border border-border bg-background p-5 flex flex-col gap-4">
|
||||
<h3 class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">Filters</h3>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template v-if="!draftFilter">
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">No filters set. All accessible books will be included in this lens.</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="t in TEMPLATES"
|
||||
:key="t.label"
|
||||
@click="applyTemplate(t)"
|
||||
class="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-dashed border-border bg-muted/30 text-foreground hover:border-primary hover:bg-primary/5 hover:text-primary transition-colors"
|
||||
>
|
||||
<Zap :size="11" class="shrink-0" />
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="initFilter"
|
||||
class="self-start flex items-center gap-1.5 h-9 px-4 rounded-lg border border-input bg-card text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Build from scratch
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Filter builder -->
|
||||
<template v-else>
|
||||
<BookFilterBuilder v-model="draftFilter" />
|
||||
<div class="flex flex-wrap items-center gap-1.5 pt-3 border-t border-border/50">
|
||||
<span class="text-xs text-muted-foreground shrink-0">Replace with template:</span>
|
||||
<button
|
||||
v-for="t in TEMPLATES"
|
||||
:key="t.label"
|
||||
@click="applyTemplate(t)"
|
||||
class="text-xs px-2.5 py-1 rounded-full border border-dashed border-border text-muted-foreground hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Sort card -->
|
||||
<div class="rounded-xl border border-border bg-background p-5 flex flex-col gap-4">
|
||||
<h3 class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">Default Sort</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<select
|
||||
:value="sortField"
|
||||
@change="sortField = ($event.target as HTMLSelectElement).value as SortField"
|
||||
class="flex-1 h-10 rounded-lg border border-input bg-card text-foreground text-sm px-3 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option v-for="(label, field) in SORT_FIELD_LABELS" :key="field" :value="field">{{ label }}</option>
|
||||
</select>
|
||||
<div class="flex rounded-lg border border-input overflow-hidden shrink-0">
|
||||
<button
|
||||
@click="setSortDir('asc')"
|
||||
class="h-10 px-4 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
sortDir === 'asc' ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
"
|
||||
>
|
||||
A to Z
|
||||
</button>
|
||||
<button
|
||||
@click="setSortDir('desc')"
|
||||
class="h-10 px-4 text-sm font-medium border-l border-input transition-colors"
|
||||
:class="
|
||||
sortDir === 'desc' ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
"
|
||||
>
|
||||
Z to A
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-t border-border shrink-0 gap-3">
|
||||
<p v-if="saveError" class="text-xs text-destructive">{{ saveError }}</p>
|
||||
<div v-else class="flex-1" />
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="h-10 px-5 rounded-lg border border-input bg-card text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="save"
|
||||
:disabled="!draftName.trim() || saving"
|
||||
class="h-10 px-6 rounded-lg bg-primary text-primary-foreground text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ saving ? 'Saving...' : 'Save changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lens-fade-enter-active,
|
||||
.lens-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.lens-fade-enter-from,
|
||||
.lens-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.lens-slide-enter-active,
|
||||
.lens-slide-leave-active {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
.lens-slide-enter-from,
|
||||
.lens-slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { BookCard, BooksPage } from '@projectx/types'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function useLens(lensId: Ref<number>) {
|
||||
const items = ref<BookCard[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(0)
|
||||
|
||||
const hasMore = computed(() => items.value.length < total.value)
|
||||
|
||||
async function load(reset = false) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
|
||||
if (reset) {
|
||||
page.value = 0
|
||||
items.value = []
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api(`/api/lenses/${lensId.value}/books?page=${page.value}&size=${PAGE_SIZE}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
const data: BooksPage = await res.json()
|
||||
|
||||
if (page.value === 0) {
|
||||
items.value = data.items
|
||||
} else {
|
||||
items.value.push(...data.items)
|
||||
}
|
||||
total.value = data.total
|
||||
page.value++
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(lensId, () => load(true))
|
||||
|
||||
return { items, total, loading, hasMore, load }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { GroupRule, SortSpec } from '@projectx/types'
|
||||
|
||||
export interface Lens {
|
||||
id: number
|
||||
userId: number
|
||||
name: string
|
||||
icon: string | null
|
||||
filter: GroupRule | null
|
||||
defaultSort: SortSpec[]
|
||||
isPublic: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateLensPayload {
|
||||
name: string
|
||||
icon?: string
|
||||
filter?: GroupRule
|
||||
defaultSort: SortSpec[]
|
||||
isPublic?: boolean
|
||||
}
|
||||
|
||||
const lenses = ref<Lens[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useLenses() {
|
||||
async function fetchLenses() {
|
||||
if (loaded.value) return
|
||||
const res = await api('/api/lenses')
|
||||
if (!res.ok) return
|
||||
lenses.value = await res.json()
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function refreshLenses() {
|
||||
const res = await api('/api/lenses')
|
||||
if (!res.ok) return
|
||||
lenses.value = await res.json()
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function createLens(payload: CreateLensPayload): Promise<Lens> {
|
||||
const res = await api('/api/lenses', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const lens: Lens = await res.json()
|
||||
lenses.value.push(lens)
|
||||
return lens
|
||||
}
|
||||
|
||||
async function updateLens(id: number, payload: Partial<CreateLensPayload>): Promise<Lens> {
|
||||
const res = await api(`/api/lenses/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const updated: Lens = await res.json()
|
||||
const idx = lenses.value.findIndex((l) => l.id === id)
|
||||
if (idx !== -1) lenses.value[idx] = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
async function deleteLens(id: number): Promise<void> {
|
||||
const res = await api(`/api/lenses/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
lenses.value = lenses.value.filter((l) => l.id !== id)
|
||||
}
|
||||
|
||||
return { lenses, fetchLenses, refreshLenses, createLens, updateLens, deleteLens }
|
||||
}
|
||||
@@ -15,6 +15,11 @@ const router = createRouter({
|
||||
name: 'library',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/lens/:id',
|
||||
name: 'lens',
|
||||
component: () => import('@/views/LensView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/read/:bookId/:fileId',
|
||||
name: 'reader',
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Search, X } from 'lucide-vue-next'
|
||||
import { ArrowDownAZ, ArrowUpAZ, Filter, X } from 'lucide-vue-next'
|
||||
import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
|
||||
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
|
||||
import BookFilterBuilder from '@/features/book/components/BookFilterBuilder.vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import SettingsDrawer from '@/features/settings/SettingsDrawer.vue'
|
||||
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
|
||||
import { useBooks } from '@/features/book/composables/useBooks'
|
||||
import { useBookQuery } from '@/features/book/composables/useBookQuery'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import type { SortField } from '@projectx/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -27,7 +29,38 @@ const libraryId = computed<number | null>(() => {
|
||||
|
||||
const title = computed(() => libraries.value.find((l) => l.id === libraryId.value)?.name ?? 'Library')
|
||||
|
||||
const { books, total, loading, error, search, hasMore, load, onSearch, clear } = useBooks(libraryId)
|
||||
const { items: books, total, loading, error, filter, sort, hasMore, load, clear } = useBookQuery(libraryId)
|
||||
|
||||
const filterOpen = ref(false)
|
||||
|
||||
const SORT_FIELD_LABELS: Record<SortField, string> = {
|
||||
title: 'Title',
|
||||
addedAt: 'Date Added',
|
||||
publishedYear: 'Published Year',
|
||||
pageCount: 'Page Count',
|
||||
seriesIndex: 'Series Index',
|
||||
}
|
||||
|
||||
const sortField = computed({
|
||||
get: () => sort.value[0]?.field ?? 'title',
|
||||
set: (field: SortField) => {
|
||||
sort.value = [{ field, dir: sort.value[0]?.dir ?? 'asc' }]
|
||||
load(true)
|
||||
},
|
||||
})
|
||||
|
||||
const sortDir = computed(() => sort.value[0]?.dir ?? 'asc')
|
||||
|
||||
function toggleSortDir() {
|
||||
sort.value = [{ field: sortField.value, dir: sortDir.value === 'asc' ? 'desc' : 'asc' }]
|
||||
load(true)
|
||||
}
|
||||
|
||||
const activeFilterCount = computed(() => filter.value?.rules?.length ?? 0)
|
||||
|
||||
function clearFilters() {
|
||||
filter.value = undefined
|
||||
}
|
||||
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
@@ -46,7 +79,7 @@ onMounted(async () => {
|
||||
if (!libraryId.value && libraries.value.length > 0) {
|
||||
router.replace({ name: 'library', params: { id: libraries.value[0]!.id } })
|
||||
} else {
|
||||
load()
|
||||
load(true)
|
||||
}
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
@@ -60,25 +93,15 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => observer?.disconnect())
|
||||
|
||||
watch(libraryId, (newId, oldId) => {
|
||||
if (newId === oldId) return
|
||||
if (newId === null) {
|
||||
clear()
|
||||
} else {
|
||||
search.value = ''
|
||||
load(true)
|
||||
}
|
||||
watch(libraryId, (newId) => {
|
||||
if (newId === null) clear()
|
||||
})
|
||||
|
||||
watch(filter, () => load(true), { deep: true })
|
||||
|
||||
watch(loading, (isLoading) => {
|
||||
if (!isLoading) loadIfSentinelVisible()
|
||||
})
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(search, () => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(onSearch, 300)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -99,17 +122,60 @@ watch(search, () => {
|
||||
<main class="flex-1 overflow-y-auto px-4 py-4" :class="backgroundClass">
|
||||
<div v-if="error" class="text-sm text-destructive mb-4">{{ error }}</div>
|
||||
|
||||
<!-- Library filter -->
|
||||
<div class="relative mb-4 max-w-xs">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" :size="13" />
|
||||
<input
|
||||
v-model="search"
|
||||
placeholder="Filter library..."
|
||||
class="w-full h-8 pl-8 pr-7 text-sm rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary transition-shadow"
|
||||
/>
|
||||
<button v-if="search" @click="search = ''" class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
<X :size="13" />
|
||||
<!-- Sort + filter toolbar -->
|
||||
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<!-- Sort field -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-muted-foreground">Sort:</span>
|
||||
<select
|
||||
:value="sortField"
|
||||
@change="sortField = ($event.target as HTMLSelectElement).value as SortField"
|
||||
class="h-8 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option v-for="(label, field) in SORT_FIELD_LABELS" :key="field" :value="field">{{ label }}</option>
|
||||
</select>
|
||||
<button
|
||||
@click="toggleSortDir"
|
||||
class="h-8 w-8 flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
:title="sortDir === 'asc' ? 'Ascending' : 'Descending'"
|
||||
>
|
||||
<ArrowDownAZ v-if="sortDir === 'asc'" :size="15" />
|
||||
<ArrowUpAZ v-else :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-5 bg-border" />
|
||||
|
||||
<!-- Filter toggle -->
|
||||
<button
|
||||
@click="filterOpen = !filterOpen"
|
||||
class="flex items-center gap-1.5 h-8 px-3 rounded-md border text-sm transition-colors"
|
||||
:class="
|
||||
activeFilterCount > 0
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
|
||||
"
|
||||
>
|
||||
<Filter :size="13" />
|
||||
<span>Filters</span>
|
||||
<span v-if="activeFilterCount > 0" class="text-xs font-semibold">({{ activeFilterCount }})</span>
|
||||
</button>
|
||||
|
||||
<!-- Clear filters -->
|
||||
<button
|
||||
v-if="activeFilterCount > 0"
|
||||
@click="clearFilters"
|
||||
class="flex items-center gap-1 h-8 px-2 rounded-md text-sm text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Clear all filters"
|
||||
>
|
||||
<X :size="13" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter builder panel -->
|
||||
<div v-if="filterOpen" class="mb-4 p-3 rounded-md border border-border bg-card">
|
||||
<BookFilterBuilder v-model="filter" />
|
||||
</div>
|
||||
|
||||
<!-- Grid view -->
|
||||
@@ -137,8 +203,8 @@ watch(search, () => {
|
||||
</div>
|
||||
|
||||
<div ref="sentinel" class="h-8 mt-4 flex items-center justify-center">
|
||||
<span v-if="loading" class="text-xs text-muted-foreground">Loading…</span>
|
||||
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground"> All {{ total.toLocaleString() }} books loaded </span>
|
||||
<span v-if="loading" class="text-xs text-muted-foreground">Loading...</span>
|
||||
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground">All {{ total.toLocaleString() }} books loaded</span>
|
||||
</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<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 BookCoverImage from '@/features/book/components/BookCoverImage.vue'
|
||||
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import SettingsDrawer from '@/features/settings/SettingsDrawer.vue'
|
||||
import LensEditorPanel from '@/features/lens/components/LensEditorPanel.vue'
|
||||
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
|
||||
import { useLens } from '@/features/lens/composables/useLens'
|
||||
import { useLenses } from '@/features/lens/composables/useLenses'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { FIELD_LABELS, ruleToLabel } from '@/features/book/lib/filter-labels'
|
||||
import type { GroupRule } from '@projectx/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const themeStore = useThemeStore()
|
||||
const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === themeStore.background)?.cssClass ?? '')
|
||||
const { coverSize, gridGap, viewMode } = useDisplaySettings()
|
||||
|
||||
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 s = lens.value?.defaultSort?.[0]
|
||||
if (!s) return null
|
||||
const fieldLabel = FIELD_LABELS[s.field as keyof typeof FIELD_LABELS] ?? s.field
|
||||
return `${fieldLabel} ${s.dir === 'asc' ? '↑' : '↓'}`
|
||||
})
|
||||
|
||||
const joinLabel = computed(() => lens.value?.filter?.join ?? 'AND')
|
||||
|
||||
const editorOpen = ref(false)
|
||||
const confirmDelete = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirmDelete.value) {
|
||||
confirmDelete.value = true
|
||||
return
|
||||
}
|
||||
deleting.value = true
|
||||
try {
|
||||
await deleteLens(lensId.value)
|
||||
router.push({ name: 'home' })
|
||||
} finally {
|
||||
deleting.value = false
|
||||
confirmDelete.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
load(true)
|
||||
}
|
||||
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
function checkSentinel() {
|
||||
if (!hasMore.value || loading.value) return
|
||||
const el = sentinel.value
|
||||
if (!el) return
|
||||
if (el.getBoundingClientRect().top < window.innerHeight + 300) load()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchLenses()
|
||||
if (!lens.value) {
|
||||
router.push({ name: 'home' })
|
||||
return
|
||||
}
|
||||
load(true)
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !loading.value) load()
|
||||
},
|
||||
{ rootMargin: '300px' },
|
||||
)
|
||||
if (sentinel.value) observer.observe(sentinel.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => observer?.disconnect())
|
||||
|
||||
watch(lensId, () => load(true))
|
||||
watch(loading, (isLoading) => {
|
||||
if (!isLoading) checkSentinel()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsDrawer />
|
||||
<LensEditorPanel :open="editorOpen" :lens="lens" @close="editorOpen = false" @saved="onSaved" />
|
||||
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
|
||||
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
|
||||
<AppHeader
|
||||
:title="lens?.name ?? 'Lens'"
|
||||
:total="total"
|
||||
:loaded="books.length"
|
||||
v-model:coverSize="coverSize"
|
||||
v-model:gridGap="gridGap"
|
||||
v-model:viewMode="viewMode"
|
||||
>
|
||||
<template #actions>
|
||||
<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"
|
||||
>
|
||||
<Settings2 :size="13" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete"
|
||||
:disabled="deleting"
|
||||
class="hidden md:flex items-center gap-1.5 h-8 px-3 rounded-md border text-sm transition-colors"
|
||||
:class="
|
||||
confirmDelete
|
||||
? 'border-destructive text-destructive bg-destructive/10 hover:bg-destructive/20'
|
||||
: 'border-input text-muted-foreground hover:text-destructive hover:border-destructive'
|
||||
"
|
||||
>
|
||||
<Trash2 :size="13" />
|
||||
<span>{{ confirmDelete ? 'Confirm?' : 'Delete' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</AppHeader>
|
||||
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
Sort: {{ sortChip }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty state: no rules configured -->
|
||||
<div v-if="!loading && !lens?.filter && books.length === 0" class="flex flex-col items-center justify-center py-24 gap-4 text-center">
|
||||
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<Settings2 :size="28" class="text-muted-foreground/50" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-sm font-medium text-foreground">No rules configured</p>
|
||||
<p class="text-xs text-muted-foreground max-w-xs">
|
||||
Open the editor to define which books appear in this lens using filters and sort rules.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="editorOpen = true"
|
||||
class="h-9 px-5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Configure Lens
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state: rules set but no matches -->
|
||||
<div v-else-if="!loading && books.length === 0 && lens?.filter" class="flex flex-col items-center justify-center py-24 gap-3 text-center">
|
||||
<p class="text-sm font-medium text-foreground">No books match this lens</p>
|
||||
<p class="text-xs text-muted-foreground">Try adjusting the filter rules.</p>
|
||||
<button @click="editorOpen = true" class="text-xs text-primary hover:underline">Edit Lens</button>
|
||||
</div>
|
||||
|
||||
<!-- Grid view -->
|
||||
<div
|
||||
v-if="viewMode === 'grid' && books.length > 0"
|
||||
class="grid"
|
||||
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))`, gap: `${gridGap}px` }"
|
||||
>
|
||||
<BookCoverCard v-for="book in books" :key="book.id" :book="book" />
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<div v-else-if="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
|
||||
<div
|
||||
v-for="book in books"
|
||||
:key="book.id"
|
||||
class="flex items-center gap-3 py-2.5 px-1 hover:bg-muted/50 rounded-md transition-colors cursor-pointer"
|
||||
>
|
||||
<BookCoverImage :book-id="book.id" type="cover" class="h-12 w-9 object-cover rounded shrink-0 bg-muted" :alt="book.title ?? ''" />
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-sm font-medium text-foreground truncate">{{ book.title ?? '-' }}</span>
|
||||
<span v-if="book.authors.length" class="text-xs text-muted-foreground truncate">{{ book.authors.join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="sentinel" class="h-8 mt-4 flex items-center justify-center">
|
||||
<span v-if="loading" class="text-xs text-muted-foreground">Loading...</span>
|
||||
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground"> All {{ total.toLocaleString() }} books loaded </span>
|
||||
</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
export type BookFileRef = {
|
||||
id: number
|
||||
format: string | null
|
||||
role: string
|
||||
}
|
||||
|
||||
export type BookCard = {
|
||||
id: number
|
||||
status: string
|
||||
title: string | null
|
||||
authors: string[]
|
||||
seriesName: string | null
|
||||
seriesIndex: number | null
|
||||
files: BookFileRef[]
|
||||
}
|
||||
|
||||
export type BooksPage = {
|
||||
items: BookCard[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from "./auth";
|
||||
export * from "./book";
|
||||
export * from "./library";
|
||||
export * from "./query";
|
||||
export * from "./reader-settings";
|
||||
export * from "./scanner";
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
export type RuleField =
|
||||
| 'title'
|
||||
| 'publisher'
|
||||
| 'language'
|
||||
| 'series'
|
||||
| 'seriesIndex'
|
||||
| 'publishedYear'
|
||||
| 'pageCount'
|
||||
| 'author'
|
||||
| 'tag'
|
||||
| 'format'
|
||||
| 'addedAt'
|
||||
|
||||
export type RuleOperator =
|
||||
| 'contains'
|
||||
| 'notContains'
|
||||
| 'startsWith'
|
||||
| 'endsWith'
|
||||
| 'eq'
|
||||
| 'notEq'
|
||||
| 'isEmpty'
|
||||
| 'isNotEmpty'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'between'
|
||||
| 'includesAny'
|
||||
| 'includesAll'
|
||||
| 'excludesAll'
|
||||
| 'before'
|
||||
| 'after'
|
||||
| 'withinLast'
|
||||
|
||||
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'],
|
||||
author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
tag: ['includesAny', 'includesAll', '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'],
|
||||
pageCount: ['gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
|
||||
addedAt: ['before', 'after', 'between', 'withinLast'],
|
||||
}
|
||||
|
||||
export const RULE_FIELDS = Object.keys(FIELD_OPERATORS) as RuleField[]
|
||||
|
||||
export const RULE_OPERATORS: RuleOperator[] = [
|
||||
'contains',
|
||||
'notContains',
|
||||
'startsWith',
|
||||
'endsWith',
|
||||
'eq',
|
||||
'notEq',
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'gt',
|
||||
'gte',
|
||||
'lt',
|
||||
'lte',
|
||||
'between',
|
||||
'includesAny',
|
||||
'includesAll',
|
||||
'excludesAll',
|
||||
'before',
|
||||
'after',
|
||||
'withinLast',
|
||||
]
|
||||
|
||||
export type Rule = {
|
||||
type: 'rule'
|
||||
field: RuleField
|
||||
operator: RuleOperator
|
||||
value?: string | number | string[] | number[]
|
||||
valueTo?: string | number
|
||||
}
|
||||
|
||||
export type GroupRule = {
|
||||
type: 'group'
|
||||
join: 'AND' | 'OR'
|
||||
rules: (Rule | GroupRule)[]
|
||||
}
|
||||
|
||||
export type SortField = 'title' | 'addedAt' | 'publishedYear' | 'pageCount' | 'seriesIndex'
|
||||
|
||||
export const SORT_FIELDS: SortField[] = ['title', 'addedAt', 'publishedYear', 'pageCount', 'seriesIndex']
|
||||
|
||||
export type SortSpec = {
|
||||
field: SortField
|
||||
dir: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export type BookQuery = {
|
||||
filter?: GroupRule
|
||||
sort: SortSpec[]
|
||||
pagination: { page: number; size: number }
|
||||
}
|
||||
+4
-3
@@ -24,7 +24,6 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@projectx/types": "workspace:*",
|
||||
"7z-wasm": "^1.2.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/formbody": "^8.0.2",
|
||||
@@ -39,6 +38,7 @@
|
||||
"@nestjs/platform-socket.io": "^11.1.12",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@nestjs/websockets": "^11.1.12",
|
||||
"@projectx/types": "workspace:*",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"chokidar": "^4.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -51,12 +51,13 @@
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.17.1",
|
||||
"pg": "^8.17.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.3",
|
||||
"unzipper": "^0.12.3"
|
||||
"unzipper": "^0.12.3",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { BookmarkModule } from './modules/bookmark/bookmark.module';
|
||||
import { BookModule } from './modules/book/book.module';
|
||||
import { KoboModule } from './modules/kobo/kobo.module';
|
||||
import { LibraryModule } from './modules/library/library.module';
|
||||
import { LensModule } from './modules/lens/lens.module';
|
||||
import { PathModule } from './modules/path/path.module';
|
||||
import { MetadataModule } from './modules/metadata/metadata.module';
|
||||
import { ReaderPreferencesModule } from './modules/reader-preferences/reader-preferences.module';
|
||||
@@ -42,6 +43,7 @@ import { UserModule } from './modules/user/user.module';
|
||||
LibraryModule,
|
||||
PathModule,
|
||||
BookModule,
|
||||
LensModule,
|
||||
ScannerModule,
|
||||
MetadataModule,
|
||||
KoboModule,
|
||||
|
||||
@@ -138,6 +138,19 @@ CREATE TABLE "library_folders" (
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "lenses" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"icon" varchar(64),
|
||||
"filter" jsonb,
|
||||
"default_sort" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"is_public" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "lenses_user_id_name_unique" UNIQUE("user_id","name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "authors" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(500) NOT NULL,
|
||||
@@ -273,12 +286,13 @@ ALTER TABLE "book_files" ADD CONSTRAINT "book_files_library_folder_id_library_fo
|
||||
ALTER TABLE "books" ADD CONSTRAINT "books_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "books" ADD CONSTRAINT "books_library_folder_id_library_folders_id_fk" FOREIGN KEY ("library_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "library_folders" ADD CONSTRAINT "library_folders_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lenses" ADD CONSTRAINT "lenses_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "book_authors" ADD CONSTRAINT "book_authors_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "book_authors" ADD CONSTRAINT "book_authors_author_id_authors_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."authors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "book_metadata" ADD CONSTRAINT "book_metadata_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "book_tags" ADD CONSTRAINT "book_tags_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "book_tags" ADD CONSTRAINT "book_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "scan_jobs" ADD CONSTRAINT "scan_jobs_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "scan_jobs" ADD CONSTRAINT "scan_jobs_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "annotations" ADD CONSTRAINT "annotations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "annotations" ADD CONSTRAINT "annotations_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
ALTER TABLE "scan_jobs" DROP CONSTRAINT "scan_jobs_library_id_libraries_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "scan_jobs" ADD CONSTRAINT "scan_jobs_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "93dc2d98-8ab8-41bc-8cc1-8cbbe6f34df1",
|
||||
"id": "58d334ba-ad72-419b-af11-f6c3dbb3ad4c",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -41,7 +41,9 @@
|
||||
"app_settings_key_unique": {
|
||||
"name": "app_settings_key_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["key"]
|
||||
"columns": [
|
||||
"key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
@@ -112,8 +114,12 @@
|
||||
"name": "password_reset_tokens_user_id_users_id_fk",
|
||||
"tableFrom": "password_reset_tokens",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -123,7 +129,9 @@
|
||||
"password_reset_tokens_token_hash_unique": {
|
||||
"name": "password_reset_tokens_token_hash_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token_hash"]
|
||||
"columns": [
|
||||
"token_hash"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
@@ -174,7 +182,9 @@
|
||||
"permissions_name_unique": {
|
||||
"name": "permissions_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["name"]
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
@@ -245,8 +255,12 @@
|
||||
"name": "refresh_tokens_user_id_users_id_fk",
|
||||
"tableFrom": "refresh_tokens",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -256,7 +270,9 @@
|
||||
"refresh_tokens_token_hash_unique": {
|
||||
"name": "refresh_tokens_token_hash_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token_hash"]
|
||||
"columns": [
|
||||
"token_hash"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
@@ -286,8 +302,12 @@
|
||||
"name": "role_permissions_role_id_roles_id_fk",
|
||||
"tableFrom": "role_permissions",
|
||||
"tableTo": "roles",
|
||||
"columnsFrom": ["role_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"role_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -295,8 +315,12 @@
|
||||
"name": "role_permissions_permission_id_permissions_id_fk",
|
||||
"tableFrom": "role_permissions",
|
||||
"tableTo": "permissions",
|
||||
"columnsFrom": ["permission_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"permission_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -304,7 +328,10 @@
|
||||
"compositePrimaryKeys": {
|
||||
"role_permissions_role_id_permission_id_pk": {
|
||||
"name": "role_permissions_role_id_permission_id_pk",
|
||||
"columns": ["role_id", "permission_id"]
|
||||
"columns": [
|
||||
"role_id",
|
||||
"permission_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
@@ -363,7 +390,9 @@
|
||||
"roles_name_unique": {
|
||||
"name": "roles_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["name"]
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
@@ -401,8 +430,12 @@
|
||||
"name": "user_library_access_user_id_users_id_fk",
|
||||
"tableFrom": "user_library_access",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -410,8 +443,12 @@
|
||||
"name": "user_library_access_library_id_libraries_id_fk",
|
||||
"tableFrom": "user_library_access",
|
||||
"tableTo": "libraries",
|
||||
"columnsFrom": ["library_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"library_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -419,7 +456,10 @@
|
||||
"compositePrimaryKeys": {
|
||||
"user_library_access_user_id_library_id_pk": {
|
||||
"name": "user_library_access_user_id_library_id_pk",
|
||||
"columns": ["user_id", "library_id"]
|
||||
"columns": [
|
||||
"user_id",
|
||||
"library_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
@@ -450,8 +490,12 @@
|
||||
"name": "user_roles_user_id_users_id_fk",
|
||||
"tableFrom": "user_roles",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -459,8 +503,12 @@
|
||||
"name": "user_roles_role_id_roles_id_fk",
|
||||
"tableFrom": "user_roles",
|
||||
"tableTo": "roles",
|
||||
"columnsFrom": ["role_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"role_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -468,7 +516,10 @@
|
||||
"compositePrimaryKeys": {
|
||||
"user_roles_user_id_role_id_pk": {
|
||||
"name": "user_roles_user_id_role_id_pk",
|
||||
"columns": ["user_id", "role_id"]
|
||||
"columns": [
|
||||
"user_id",
|
||||
"role_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
@@ -585,12 +636,16 @@
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["username"]
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
},
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
@@ -689,8 +744,12 @@
|
||||
"name": "book_files_book_id_books_id_fk",
|
||||
"tableFrom": "book_files",
|
||||
"tableTo": "books",
|
||||
"columnsFrom": ["book_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"book_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -698,8 +757,12 @@
|
||||
"name": "book_files_library_folder_id_library_folders_id_fk",
|
||||
"tableFrom": "book_files",
|
||||
"tableTo": "library_folders",
|
||||
"columnsFrom": ["library_folder_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"library_folder_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -766,8 +829,12 @@
|
||||
"name": "books_library_id_libraries_id_fk",
|
||||
"tableFrom": "books",
|
||||
"tableTo": "libraries",
|
||||
"columnsFrom": ["library_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"library_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -775,8 +842,12 @@
|
||||
"name": "books_library_folder_id_library_folders_id_fk",
|
||||
"tableFrom": "books",
|
||||
"tableTo": "library_folders",
|
||||
"columnsFrom": ["library_folder_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"library_folder_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -949,8 +1020,12 @@
|
||||
"name": "library_folders_library_id_libraries_id_fk",
|
||||
"tableFrom": "library_folders",
|
||||
"tableTo": "libraries",
|
||||
"columnsFrom": ["library_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"library_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -961,6 +1036,100 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.lenses": {
|
||||
"name": "lenses",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"filter": {
|
||||
"name": "filter",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"default_sort": {
|
||||
"name": "default_sort",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"lenses_user_id_users_id_fk": {
|
||||
"name": "lenses_user_id_users_id_fk",
|
||||
"tableFrom": "lenses",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"lenses_user_id_name_unique": {
|
||||
"name": "lenses_user_id_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"user_id",
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.authors": {
|
||||
"name": "authors",
|
||||
"schema": "",
|
||||
@@ -1022,8 +1191,12 @@
|
||||
"name": "book_authors_book_id_books_id_fk",
|
||||
"tableFrom": "book_authors",
|
||||
"tableTo": "books",
|
||||
"columnsFrom": ["book_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"book_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -1031,8 +1204,12 @@
|
||||
"name": "book_authors_author_id_authors_id_fk",
|
||||
"tableFrom": "book_authors",
|
||||
"tableTo": "authors",
|
||||
"columnsFrom": ["author_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"author_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -1040,7 +1217,10 @@
|
||||
"compositePrimaryKeys": {
|
||||
"book_authors_book_id_author_id_pk": {
|
||||
"name": "book_authors_book_id_author_id_pk",
|
||||
"columns": ["book_id", "author_id"]
|
||||
"columns": [
|
||||
"book_id",
|
||||
"author_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
@@ -1138,8 +1318,12 @@
|
||||
"name": "book_metadata_book_id_books_id_fk",
|
||||
"tableFrom": "book_metadata",
|
||||
"tableTo": "books",
|
||||
"columnsFrom": ["book_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"book_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -1173,8 +1357,12 @@
|
||||
"name": "book_tags_book_id_books_id_fk",
|
||||
"tableFrom": "book_tags",
|
||||
"tableTo": "books",
|
||||
"columnsFrom": ["book_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"book_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -1182,8 +1370,12 @@
|
||||
"name": "book_tags_tag_id_tags_id_fk",
|
||||
"tableFrom": "book_tags",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": ["tag_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -1191,7 +1383,10 @@
|
||||
"compositePrimaryKeys": {
|
||||
"book_tags_book_id_tag_id_pk": {
|
||||
"name": "book_tags_book_id_tag_id_pk",
|
||||
"columns": ["book_id", "tag_id"]
|
||||
"columns": [
|
||||
"book_id",
|
||||
"tag_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
@@ -1223,7 +1418,9 @@
|
||||
"tags_name_unique": {
|
||||
"name": "tags_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["name"]
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
@@ -1306,9 +1503,13 @@
|
||||
"name": "scan_jobs_library_id_libraries_id_fk",
|
||||
"tableFrom": "scan_jobs",
|
||||
"tableTo": "libraries",
|
||||
"columnsFrom": ["library_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"columnsFrom": [
|
||||
"library_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
@@ -1415,8 +1616,12 @@
|
||||
"name": "annotations_user_id_users_id_fk",
|
||||
"tableFrom": "annotations",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -1424,8 +1629,12 @@
|
||||
"name": "annotations_book_id_books_id_fk",
|
||||
"tableFrom": "annotations",
|
||||
"tableTo": "books",
|
||||
"columnsFrom": ["book_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"book_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -1500,8 +1709,12 @@
|
||||
"name": "bookmarks_user_id_users_id_fk",
|
||||
"tableFrom": "bookmarks",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -1509,8 +1722,12 @@
|
||||
"name": "bookmarks_book_id_books_id_fk",
|
||||
"tableFrom": "bookmarks",
|
||||
"tableTo": "books",
|
||||
"columnsFrom": ["book_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"book_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -1585,8 +1802,12 @@
|
||||
"name": "reader_default_preferences_user_id_users_id_fk",
|
||||
"tableFrom": "reader_default_preferences",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -1661,8 +1882,12 @@
|
||||
"name": "reader_preferences_user_id_users_id_fk",
|
||||
"tableFrom": "reader_preferences",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -1670,8 +1895,12 @@
|
||||
"name": "reader_preferences_book_file_id_book_files_id_fk",
|
||||
"tableFrom": "reader_preferences",
|
||||
"tableTo": "book_files",
|
||||
"columnsFrom": ["book_file_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"book_file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -1747,8 +1976,12 @@
|
||||
"name": "reading_progress_book_file_id_book_files_id_fk",
|
||||
"tableFrom": "reading_progress",
|
||||
"tableTo": "book_files",
|
||||
"columnsFrom": ["book_file_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"book_file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -1756,8 +1989,12 @@
|
||||
"name": "reading_progress_user_id_users_id_fk",
|
||||
"tableFrom": "reading_progress",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -1765,7 +2002,10 @@
|
||||
"compositePrimaryKeys": {
|
||||
"reading_progress_book_file_id_user_id_pk": {
|
||||
"name": "reading_progress_book_file_id_user_id_pk",
|
||||
"columns": ["book_file_id", "user_id"]
|
||||
"columns": [
|
||||
"book_file_id",
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
@@ -1809,8 +2049,12 @@
|
||||
"name": "oidc_group_mappings_role_id_roles_id_fk",
|
||||
"tableFrom": "oidc_group_mappings",
|
||||
"tableTo": "roles",
|
||||
"columnsFrom": ["role_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"role_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -1820,7 +2064,9 @@
|
||||
"oidc_group_mappings_oidc_group_claim_unique": {
|
||||
"name": "oidc_group_mappings_oidc_group_claim_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["oidc_group_claim"]
|
||||
"columns": [
|
||||
"oidc_group_claim"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
@@ -1940,8 +2186,12 @@
|
||||
"name": "oidc_sessions_user_id_users_id_fk",
|
||||
"tableFrom": "oidc_sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -1957,7 +2207,11 @@
|
||||
"public.library_access_level": {
|
||||
"name": "library_access_level",
|
||||
"schema": "public",
|
||||
"values": ["viewer", "editor", "owner"]
|
||||
"values": [
|
||||
"viewer",
|
||||
"editor",
|
||||
"owner"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,16 +5,9 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1769077922285,
|
||||
"when": 1769086950417,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1769077922285,
|
||||
"tag": "0001_add_scan_jobs_cascade",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './auth';
|
||||
export * from './libraries';
|
||||
export * from './books';
|
||||
export * from './lenses';
|
||||
export * from './metadata';
|
||||
export * from './scanner';
|
||||
export * from './reader';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { boolean, integer, jsonb, pgTable, serial, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { users } from './auth';
|
||||
|
||||
export const lenses = pgTable(
|
||||
'lenses',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
icon: varchar('icon', { length: 64 }),
|
||||
filter: jsonb('filter'),
|
||||
defaultSort: jsonb('default_sort').notNull().default([]),
|
||||
isPublic: boolean('is_public').notNull().default(false),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.name)],
|
||||
);
|
||||
|
||||
export type Lens = typeof lenses.$inferSelect;
|
||||
export type NewLens = typeof lenses.$inferInsert;
|
||||
@@ -0,0 +1,214 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { AnyColumn, SQL, and, eq, gt, gte, ilike, inArray, isNotNull, isNull, lt, lte, ne, not, or, sql } from 'drizzle-orm';
|
||||
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, bookMetadata, books, bookTags, tags } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
const SORT_FIELD_MAP: Record<SortField, AnyColumn> = {
|
||||
title: bookMetadata.title,
|
||||
addedAt: books.addedAt,
|
||||
publishedYear: bookMetadata.publishedYear,
|
||||
pageCount: bookMetadata.pageCount,
|
||||
seriesIndex: bookMetadata.seriesIndex,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BookQueryBuilder {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
buildWhere(filter: GroupRule | null | undefined, ctx: { accessibleLibraryIds: number[]; implicitLibraryId?: number }): SQL | undefined {
|
||||
if (ctx.accessibleLibraryIds.length === 0) {
|
||||
return sql`1 = 0`;
|
||||
}
|
||||
|
||||
const clauses: SQL[] = [inArray(books.libraryId, ctx.accessibleLibraryIds)];
|
||||
|
||||
if (ctx.implicitLibraryId !== undefined) {
|
||||
clauses.push(eq(books.libraryId, ctx.implicitLibraryId));
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
clauses.push(this.groupToSql(filter, 0));
|
||||
}
|
||||
|
||||
return and(...clauses);
|
||||
}
|
||||
|
||||
buildOrderBy(sort: SortSpec[]): SQL[] {
|
||||
if (sort.length === 0) return [sql`${bookMetadata.title} ASC NULLS LAST`];
|
||||
const result: SQL[] = [];
|
||||
for (const { field, dir } of sort) {
|
||||
const col = SORT_FIELD_MAP[field];
|
||||
result.push(sql`${col} ${sql.raw(dir.toUpperCase())} NULLS LAST`);
|
||||
if (field === 'seriesIndex') {
|
||||
result.push(sql`${bookMetadata.seriesName} ${sql.raw(dir.toUpperCase())} NULLS LAST`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private groupToSql(node: GroupRule, depth: number): SQL {
|
||||
if (depth > 5) throw new BadRequestException('Filter nesting exceeds maximum depth of 5');
|
||||
const clauses = node.rules.map((r) => (r.type === 'group' ? this.groupToSql(r, depth + 1) : this.ruleToSql(r)));
|
||||
return node.join === 'AND' ? and(...clauses)! : or(...clauses)!;
|
||||
}
|
||||
|
||||
private ruleToSql(rule: Rule): SQL {
|
||||
const { field, operator, value, valueTo } = rule;
|
||||
switch (field) {
|
||||
case 'title':
|
||||
return this.textRuleToSql(bookMetadata.title, operator, value as string);
|
||||
case 'publisher':
|
||||
return this.textRuleToSql(bookMetadata.publisher, operator, value as string);
|
||||
case 'language':
|
||||
return this.textRuleToSql(bookMetadata.language, operator, value as string);
|
||||
case 'series':
|
||||
return 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':
|
||||
return this.numericRuleToSql(bookMetadata.seriesIndex, operator, value as number, valueTo as number | undefined);
|
||||
case 'pageCount':
|
||||
return this.numericRuleToSql(bookMetadata.pageCount, operator, value as number, valueTo as number | undefined);
|
||||
case 'author':
|
||||
return this.authorRuleToSql(operator, value as string[]);
|
||||
case 'tag':
|
||||
return this.tagRuleToSql(operator, value as string[]);
|
||||
case 'format':
|
||||
return this.formatRuleToSql(operator, value as string[]);
|
||||
case 'addedAt':
|
||||
return this.dateRuleToSql(operator, value as string | number, valueTo as string | undefined);
|
||||
default:
|
||||
throw new BadRequestException(`Unknown filter field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
private textRuleToSql(col: AnyColumn, operator: string, value?: string): SQL {
|
||||
switch (operator) {
|
||||
case 'contains':
|
||||
return ilike(col, `%${value}%`);
|
||||
case 'notContains':
|
||||
return not(ilike(col, `%${value}%`));
|
||||
case 'startsWith':
|
||||
return ilike(col, `${value}%`);
|
||||
case 'endsWith':
|
||||
return ilike(col, `%${value}`);
|
||||
case 'eq':
|
||||
return ilike(col, value!);
|
||||
case 'notEq':
|
||||
return not(ilike(col, value!));
|
||||
case 'isEmpty':
|
||||
return isNull(col);
|
||||
case 'isNotEmpty':
|
||||
return isNotNull(col);
|
||||
default:
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for text field`);
|
||||
}
|
||||
}
|
||||
|
||||
private numericRuleToSql(col: AnyColumn, operator: string, value?: number, valueTo?: number): SQL {
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return eq(col, value!);
|
||||
case 'notEq':
|
||||
return ne(col, value!);
|
||||
case 'gt':
|
||||
return gt(col, value!);
|
||||
case 'gte':
|
||||
return gte(col, value!);
|
||||
case 'lt':
|
||||
return lt(col, value!);
|
||||
case 'lte':
|
||||
return lte(col, value!);
|
||||
case 'between':
|
||||
return and(gte(col, value!), lte(col, valueTo!))!;
|
||||
case 'isEmpty':
|
||||
return isNull(col);
|
||||
case 'isNotEmpty':
|
||||
return isNotNull(col);
|
||||
default:
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for numeric field`);
|
||||
}
|
||||
}
|
||||
|
||||
private authorRuleToSql(operator: string, values?: string[]): SQL {
|
||||
const sq = (whereClause?: SQL) =>
|
||||
this.db.select({ bookId: bookAuthors.bookId }).from(bookAuthors).innerJoin(authors, eq(bookAuthors.authorId, authors.id)).where(whereClause);
|
||||
|
||||
switch (operator) {
|
||||
case 'includesAny':
|
||||
if (!values?.length) return sql`1 = 0`;
|
||||
return inArray(books.id, sq(or(...values.map((v) => ilike(authors.name, `%${v}%`)))!));
|
||||
case 'includesAll':
|
||||
if (!values?.length) return sql`1 = 0`;
|
||||
return and(...values.map((v) => inArray(books.id, sq(ilike(authors.name, `%${v}%`)))))!;
|
||||
case 'excludesAll':
|
||||
if (!values?.length) return sql`1 = 1`;
|
||||
return not(inArray(books.id, sq(or(...values.map((v) => ilike(authors.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 author field`);
|
||||
}
|
||||
}
|
||||
|
||||
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 formatRuleToSql(operator: string, values?: string[]): SQL {
|
||||
const sq = (whereClause?: SQL) => this.db.select({ bookId: bookFiles.bookId }).from(bookFiles).where(whereClause);
|
||||
|
||||
switch (operator) {
|
||||
case 'includesAny':
|
||||
if (!values?.length) return sql`1 = 0`;
|
||||
return inArray(books.id, sq(inArray(bookFiles.format, values)));
|
||||
case 'excludesAll':
|
||||
if (!values?.length) return sql`1 = 1`;
|
||||
return not(inArray(books.id, sq(inArray(bookFiles.format, values))));
|
||||
default:
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for format field`);
|
||||
}
|
||||
}
|
||||
|
||||
private dateRuleToSql(operator: string, value?: string | number, valueTo?: string): SQL {
|
||||
switch (operator) {
|
||||
case 'before':
|
||||
return lt(books.addedAt, new Date(value as string));
|
||||
case 'after':
|
||||
return gt(books.addedAt, new Date(value as string));
|
||||
case 'between':
|
||||
return and(gte(books.addedAt, new Date(value as string)), lte(books.addedAt, new Date(valueTo!)))!;
|
||||
case 'withinLast':
|
||||
return sql`${books.addedAt} >= NOW() - (${value} * INTERVAL '1 day')`;
|
||||
default:
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for date field`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,25 +5,26 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookService } from './book.service';
|
||||
import { GetBooksDto } from './dto/get-books.dto';
|
||||
import { BookQueryPipe } from './pipes/book-query.pipe';
|
||||
import { SaveProgressDto } from './dto/save-progress.dto';
|
||||
import { SearchBooksDto } from './dto/search-books.dto';
|
||||
import type { BookQuery } from '@projectx/types';
|
||||
|
||||
@Controller('books')
|
||||
export class BookController {
|
||||
constructor(private readonly bookService: BookService) {}
|
||||
|
||||
@Get()
|
||||
getCards(@Query() dto: GetBooksDto, @CurrentUser() user: RequestUser) {
|
||||
return this.bookService.getCards(dto, user);
|
||||
}
|
||||
|
||||
// Must be before @Get(':id') so NestJS does not treat 'search' as an :id param
|
||||
@Get('search')
|
||||
searchBooks(@Query() dto: SearchBooksDto, @CurrentUser() user: RequestUser) {
|
||||
return this.bookService.searchAcrossLibraries(dto.q, dto.limit ?? 10, user);
|
||||
}
|
||||
|
||||
@Post('query')
|
||||
globalQuery(@Body(BookQueryPipe) query: BookQuery, @CurrentUser() user: RequestUser) {
|
||||
return this.bookService.globalQuery(user, query);
|
||||
}
|
||||
|
||||
@Get(':id/cover')
|
||||
async getCover(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
|
||||
const coverPath = await this.bookService.getCoverPath(id, user);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
|
||||
import { LibraryModule } from '../library/library.module';
|
||||
import { BookQueryBuilder } from './book-query-builder.service';
|
||||
import { BookController } from './book.controller';
|
||||
import { BookRepository } from './book.repository';
|
||||
import { BookService } from './book.service';
|
||||
|
||||
@Module({
|
||||
imports: [LibraryModule],
|
||||
imports: [forwardRef(() => LibraryModule)],
|
||||
controllers: [BookController],
|
||||
providers: [BookService, BookRepository],
|
||||
exports: [BookService, BookRepository],
|
||||
providers: [BookService, BookRepository, BookQueryBuilder],
|
||||
exports: [BookService, BookRepository, BookQueryBuilder],
|
||||
})
|
||||
export class BookModule {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, count, eq, ilike, inArray, sql } from 'drizzle-orm';
|
||||
import { SQL, and, count, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
@@ -12,13 +12,8 @@ type Db = NodePgDatabase<typeof schema>;
|
||||
export class BookRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async findCards(libraryId: number, opts: { page: number; size: number; search?: string }) {
|
||||
const { page, size, search } = opts;
|
||||
const offset = page * size;
|
||||
|
||||
const searchFilter = search?.trim() ? ilike(bookMetadata.title, `%${search.trim()}%`) : undefined;
|
||||
|
||||
const where = and(eq(books.libraryId, libraryId), searchFilter);
|
||||
async findCards(opts: { where: SQL | undefined; orderBy: SQL[]; limit: number; offset: number }) {
|
||||
const { where, orderBy, limit, offset } = opts;
|
||||
|
||||
const [rows, [{ total }]] = await Promise.all([
|
||||
this.db
|
||||
@@ -33,8 +28,8 @@ export class BookRepository {
|
||||
.from(books)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.where(where)
|
||||
.orderBy(bookMetadata.title)
|
||||
.limit(size)
|
||||
.orderBy(...orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
this.db.select({ total: count() }).from(books).leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)).where(where),
|
||||
]);
|
||||
@@ -132,7 +127,7 @@ export class BookRepository {
|
||||
.from(books)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.innerJoin(libraries, eq(libraries.id, books.libraryId))
|
||||
.where(and(inArray(books.libraryId, libraryIds), ilike(bookMetadata.title, `%${q}%`)))
|
||||
.where(and(inArray(books.libraryId, libraryIds), sql`${bookMetadata.title} ILIKE ${'%' + q + '%'}`))
|
||||
.orderBy(bookMetadata.title)
|
||||
.limit(limit);
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { access, readdir, stat } from 'fs/promises';
|
||||
import { basename, join } from 'path';
|
||||
|
||||
import type { BookCard, BookQuery, BooksPage } from '@projectx/types';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { LibraryService } from '../library/library.service';
|
||||
import { BookQueryBuilder } from './book-query-builder.service';
|
||||
import { BookRepository } from './book.repository';
|
||||
import { BookCardDto } from './dto/book-card.dto';
|
||||
import { BookDetailDto } from './dto/book-detail.dto';
|
||||
import { GetBooksDto } from './dto/get-books.dto';
|
||||
import { SaveProgressDto } from './dto/save-progress.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BookService {
|
||||
@@ -17,6 +18,7 @@ export class BookService {
|
||||
constructor(
|
||||
private readonly bookRepo: BookRepository,
|
||||
private readonly libraryService: LibraryService,
|
||||
private readonly queryBuilder: BookQueryBuilder,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.booksPath = this.config.get<string>('storage.booksPath')!;
|
||||
@@ -39,11 +41,11 @@ export class BookService {
|
||||
return file;
|
||||
}
|
||||
|
||||
async getCards(dto: GetBooksDto, user: RequestUser): Promise<{ items: BookCardDto[]; total: number; page: number; size: number }> {
|
||||
await this.libraryService.verifyUserAccess(user.id, dto.libraryId, this.isSuperuser(user));
|
||||
const { libraryId, page = 0, size = 50, search } = dto;
|
||||
const { rows, authorRows, fileRows, total } = await this.bookRepo.findCards(libraryId, { page, size, search });
|
||||
|
||||
private assembleBookCards(
|
||||
rows: { id: number; status: string; folderPath: string; title: string | null; seriesName: string | null; seriesIndex: number | null }[],
|
||||
authorRows: { bookId: number; name: string }[],
|
||||
fileRows: { bookId: number; id: number; format: string | null; role: string }[],
|
||||
): BookCard[] {
|
||||
const authorsByBook = new Map<number, string[]>();
|
||||
for (const row of authorRows) {
|
||||
const list = authorsByBook.get(row.bookId) ?? [];
|
||||
@@ -58,7 +60,7 @@ export class BookService {
|
||||
filesByBook.set(row.bookId, list);
|
||||
}
|
||||
|
||||
const items: BookCardDto[] = rows.map((row) => ({
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
title: row.title ?? basename(row.folderPath),
|
||||
@@ -67,8 +69,33 @@ export class BookService {
|
||||
authors: authorsByBook.get(row.id) ?? [],
|
||||
files: filesByBook.get(row.id) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
return { items, total, page, size };
|
||||
async queryForLibrary(user: RequestUser, libraryId: number, query: BookQuery): Promise<BooksPage> {
|
||||
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
|
||||
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds: [libraryId], implicitLibraryId: libraryId });
|
||||
const orderBy = this.queryBuilder.buildOrderBy(query.sort);
|
||||
const { rows, authorRows, fileRows, total } = await this.bookRepo.findCards({
|
||||
where,
|
||||
orderBy,
|
||||
limit: query.pagination.size,
|
||||
offset: query.pagination.page * query.pagination.size,
|
||||
});
|
||||
return { items: this.assembleBookCards(rows, authorRows, fileRows), total, page: query.pagination.page, size: query.pagination.size };
|
||||
}
|
||||
|
||||
async globalQuery(user: RequestUser, query: BookQuery): Promise<BooksPage> {
|
||||
const libs = await this.libraryService.findAll(user);
|
||||
const accessibleLibraryIds = libs.map((l) => l.id);
|
||||
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds });
|
||||
const orderBy = this.queryBuilder.buildOrderBy(query.sort);
|
||||
const { rows, authorRows, fileRows, total } = await this.bookRepo.findCards({
|
||||
where,
|
||||
orderBy,
|
||||
limit: query.pagination.size,
|
||||
offset: query.pagination.page * query.pagination.size,
|
||||
});
|
||||
return { items: this.assembleBookCards(rows, authorRows, fileRows), total, page: query.pagination.page, size: query.pagination.size };
|
||||
}
|
||||
|
||||
async getCoverPath(id: number, user: RequestUser): Promise<string | null> {
|
||||
@@ -108,12 +135,7 @@ export class BookService {
|
||||
return this.bookRepo.findProgress(userId, fileId);
|
||||
}
|
||||
|
||||
async saveProgress(
|
||||
userId: number,
|
||||
fileId: number,
|
||||
dto: { cfi?: string | null; pageNumber?: number | null; percentage: number },
|
||||
user: RequestUser,
|
||||
) {
|
||||
async saveProgress(userId: number, fileId: number, dto: SaveProgressDto, user: RequestUser) {
|
||||
await this.verifyFileAccess(fileId, user);
|
||||
await this.bookRepo.upsertProgress(userId, fileId, dto.cfi ?? null, dto.pageNumber ?? null, dto.percentage);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BookQuery, GroupRule, Rule } from '@projectx/types';
|
||||
|
||||
// Inlined here to avoid a runtime require() of the @projectx/types TS source.
|
||||
// Keep in sync with packages/types/src/query.ts.
|
||||
const RULE_FIELDS = [
|
||||
'title',
|
||||
'publisher',
|
||||
'language',
|
||||
'series',
|
||||
'seriesIndex',
|
||||
'publishedYear',
|
||||
'pageCount',
|
||||
'author',
|
||||
'tag',
|
||||
'format',
|
||||
'addedAt',
|
||||
] as const;
|
||||
|
||||
const RULE_OPERATORS = [
|
||||
'contains',
|
||||
'notContains',
|
||||
'startsWith',
|
||||
'endsWith',
|
||||
'eq',
|
||||
'notEq',
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'gt',
|
||||
'gte',
|
||||
'lt',
|
||||
'lte',
|
||||
'between',
|
||||
'includesAny',
|
||||
'includesAll',
|
||||
'excludesAll',
|
||||
'before',
|
||||
'after',
|
||||
'withinLast',
|
||||
] as const;
|
||||
|
||||
const SORT_FIELDS = ['title', 'addedAt', 'publishedYear', 'pageCount', 'seriesIndex'] as const;
|
||||
|
||||
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'],
|
||||
author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
tag: ['includesAny', 'includesAll', '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'],
|
||||
pageCount: ['gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
|
||||
addedAt: ['before', 'after', 'between', 'withinLast'],
|
||||
};
|
||||
|
||||
const ruleSchema: z.ZodType<Rule> = z
|
||||
.object({
|
||||
type: z.literal('rule'),
|
||||
field: z.enum(RULE_FIELDS),
|
||||
operator: z.enum(RULE_OPERATORS),
|
||||
value: z.union([z.string(), z.number(), z.array(z.string()).max(20), z.array(z.number()).max(20)]).optional(),
|
||||
valueTo: z.union([z.string(), z.number()]).optional(),
|
||||
})
|
||||
.refine((rule) => !!VALID_OPERATORS_BY_FIELD[rule.field]?.includes(rule.operator), {
|
||||
message: 'Operator is not valid for this field',
|
||||
}) as z.ZodType<Rule>;
|
||||
|
||||
const groupRuleSchema = (maxDepth: number): z.ZodType<GroupRule> =>
|
||||
z.object({
|
||||
type: z.literal('group'),
|
||||
join: z.enum(['AND', 'OR']),
|
||||
rules: z.array(maxDepth <= 1 ? ruleSchema : z.union([ruleSchema, z.lazy(() => groupRuleSchema(maxDepth - 1))])).min(1),
|
||||
}) as z.ZodType<GroupRule>;
|
||||
|
||||
const bookQuerySchema = z.object({
|
||||
filter: groupRuleSchema(5).optional(),
|
||||
sort: z
|
||||
.array(
|
||||
z.object({
|
||||
field: z.enum(SORT_FIELDS),
|
||||
dir: z.enum(['asc', 'desc']),
|
||||
}),
|
||||
)
|
||||
.max(5)
|
||||
.default([]),
|
||||
pagination: z
|
||||
.object({
|
||||
page: z.number().int().min(0).default(0),
|
||||
size: z.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
.default({ page: 0, size: 50 }),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class BookQueryPipe implements PipeTransform {
|
||||
transform(value: unknown): BookQuery {
|
||||
const result = bookQuerySchema.safeParse(value ?? {});
|
||||
if (!result.success) throw new BadRequestException(result.error.flatten());
|
||||
return result.data as BookQuery;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { GroupRule, Rule } from '@projectx/types';
|
||||
|
||||
// Constants inlined to avoid a runtime require() of the @projectx/types TS source.
|
||||
// Keep in sync with packages/types/src/query.ts.
|
||||
const RULE_FIELDS = [
|
||||
'title',
|
||||
'publisher',
|
||||
'language',
|
||||
'series',
|
||||
'seriesIndex',
|
||||
'publishedYear',
|
||||
'pageCount',
|
||||
'author',
|
||||
'tag',
|
||||
'format',
|
||||
'addedAt',
|
||||
] as const;
|
||||
const RULE_OPERATORS = [
|
||||
'contains',
|
||||
'notContains',
|
||||
'startsWith',
|
||||
'endsWith',
|
||||
'eq',
|
||||
'notEq',
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'gt',
|
||||
'gte',
|
||||
'lt',
|
||||
'lte',
|
||||
'between',
|
||||
'includesAny',
|
||||
'includesAll',
|
||||
'excludesAll',
|
||||
'before',
|
||||
'after',
|
||||
'withinLast',
|
||||
] as const;
|
||||
|
||||
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'],
|
||||
author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
|
||||
tag: ['includesAny', 'includesAll', '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'],
|
||||
pageCount: ['gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
|
||||
addedAt: ['before', 'after', 'between', 'withinLast'],
|
||||
};
|
||||
|
||||
const ruleSchema: z.ZodType<Rule> = z
|
||||
.object({
|
||||
type: z.literal('rule'),
|
||||
field: z.enum(RULE_FIELDS),
|
||||
operator: z.enum(RULE_OPERATORS),
|
||||
value: z.union([z.string(), z.number(), z.array(z.string().min(1)).min(1).max(20), z.array(z.number()).min(1).max(20)]).optional(),
|
||||
valueTo: z.union([z.string(), z.number()]).optional(),
|
||||
})
|
||||
.refine((rule) => !!VALID_OPERATORS_BY_FIELD[rule.field]?.includes(rule.operator), {
|
||||
message: 'Operator is not valid for this field',
|
||||
}) as z.ZodType<Rule>;
|
||||
|
||||
const groupRuleSchema = (maxDepth: number): z.ZodType<GroupRule> =>
|
||||
z.object({
|
||||
type: z.literal('group'),
|
||||
join: z.enum(['AND', 'OR']),
|
||||
rules: z.array(maxDepth <= 1 ? ruleSchema : z.union([ruleSchema, z.lazy(() => groupRuleSchema(maxDepth - 1))])).min(1),
|
||||
}) as z.ZodType<GroupRule>;
|
||||
|
||||
export function validateGroupRule(value: unknown): GroupRule | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const result = groupRuleSchema(5).safeParse(value);
|
||||
if (!result.success) throw new BadRequestException({ message: 'Invalid filter', errors: result.error.flatten() });
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { IsArray, IsBoolean, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class SortSpecDto {
|
||||
@IsString()
|
||||
field: string;
|
||||
|
||||
@IsString()
|
||||
dir: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export class CreateLensDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
filter?: Record<string, unknown>;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SortSpecDto)
|
||||
defaultSort: SortSpecDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPublic?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { IsArray, IsBoolean, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { SortSpecDto } from './create-lens.dto';
|
||||
|
||||
export class UpdateLensDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
filter?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SortSpecDto)
|
||||
defaultSort?: SortSpecDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPublic?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Body, Controller, DefaultValuePipe, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { CreateLensDto } from './dto/create-lens.dto';
|
||||
import { UpdateLensDto } from './dto/update-lens.dto';
|
||||
import { LensService } from './lens.service';
|
||||
|
||||
@Controller('lenses')
|
||||
export class LensController {
|
||||
constructor(private readonly lensService: LensService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@CurrentUser() user: RequestUser) {
|
||||
return this.lensService.findAll(user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
||||
return this.lensService.findOne(id, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateLensDto, @CurrentUser() user: RequestUser) {
|
||||
return this.lensService.create(dto, user);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateLensDto, @CurrentUser() user: RequestUser) {
|
||||
return this.lensService.update(id, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
||||
return this.lensService.remove(id, user);
|
||||
}
|
||||
|
||||
@Get(':id/books')
|
||||
executeLens(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query('page', new DefaultValuePipe(0), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(50), ParseIntPipe) size: number,
|
||||
) {
|
||||
return this.lensService.executeLens(id, user, page, size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { BookModule } from '../book/book.module';
|
||||
import { LibraryModule } from '../library/library.module';
|
||||
import { LensController } from './lens.controller';
|
||||
import { LensRepository } from './lens.repository';
|
||||
import { LensService } from './lens.service';
|
||||
|
||||
@Module({
|
||||
imports: [BookModule, LibraryModule],
|
||||
controllers: [LensController],
|
||||
providers: [LensService, LensRepository],
|
||||
})
|
||||
export class LensModule {}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, or, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { lenses } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class LensRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
findAllForUser(userId: number) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(lenses)
|
||||
.where(or(eq(lenses.userId, userId), eq(lenses.isPublic, true)))
|
||||
.orderBy(lenses.name);
|
||||
}
|
||||
|
||||
findById(id: number) {
|
||||
return this.db.select().from(lenses).where(eq(lenses.id, id)).limit(1);
|
||||
}
|
||||
|
||||
insert(values: typeof lenses.$inferInsert) {
|
||||
return this.db.insert(lenses).values(values).returning();
|
||||
}
|
||||
|
||||
update(id: number, userId: number, values: Partial<typeof lenses.$inferInsert>) {
|
||||
return this.db
|
||||
.update(lenses)
|
||||
.set({ ...values, updatedAt: sql`now()` })
|
||||
.where(and(eq(lenses.id, id), eq(lenses.userId, userId)))
|
||||
.returning();
|
||||
}
|
||||
|
||||
delete(id: number, userId: number) {
|
||||
return this.db
|
||||
.delete(lenses)
|
||||
.where(and(eq(lenses.id, id), eq(lenses.userId, userId)))
|
||||
.returning();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { basename } from 'path';
|
||||
|
||||
import type { BookCard, BooksPage, GroupRule, SortSpec } from '@projectx/types';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookQueryBuilder } from '../book/book-query-builder.service';
|
||||
import { BookRepository } from '../book/book.repository';
|
||||
import { validateGroupRule } from '../book/utils/group-rule.validator';
|
||||
import { LibraryService } from '../library/library.service';
|
||||
import { CreateLensDto } from './dto/create-lens.dto';
|
||||
import { UpdateLensDto } from './dto/update-lens.dto';
|
||||
import { LensRepository } from './lens.repository';
|
||||
|
||||
@Injectable()
|
||||
export class LensService {
|
||||
constructor(
|
||||
private readonly lensRepo: LensRepository,
|
||||
private readonly bookRepo: BookRepository,
|
||||
private readonly queryBuilder: BookQueryBuilder,
|
||||
private readonly libraryService: LibraryService,
|
||||
) {}
|
||||
|
||||
private isSuperuser(user: RequestUser): boolean {
|
||||
return user.roles.some((r) => r.isSuperuser);
|
||||
}
|
||||
|
||||
private assembleBookCards(
|
||||
rows: { id: number; status: string; folderPath: string; title: string | null; seriesName: string | null; seriesIndex: number | null }[],
|
||||
authorRows: { bookId: number; name: string }[],
|
||||
fileRows: { bookId: number; id: number; format: string | null; role: string }[],
|
||||
): BookCard[] {
|
||||
const authorsByBook = new Map<number, string[]>();
|
||||
for (const row of authorRows) {
|
||||
const list = authorsByBook.get(row.bookId) ?? [];
|
||||
list.push(row.name);
|
||||
authorsByBook.set(row.bookId, list);
|
||||
}
|
||||
|
||||
const filesByBook = new Map<number, { id: number; format: string | null; role: string }[]>();
|
||||
for (const row of fileRows) {
|
||||
const list = filesByBook.get(row.bookId) ?? [];
|
||||
list.push({ id: row.id, format: row.format, role: row.role });
|
||||
filesByBook.set(row.bookId, list);
|
||||
}
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
title: row.title ?? basename(row.folderPath),
|
||||
seriesName: row.seriesName ?? null,
|
||||
seriesIndex: row.seriesIndex ?? null,
|
||||
authors: authorsByBook.get(row.id) ?? [],
|
||||
files: filesByBook.get(row.id) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
findAll(user: RequestUser) {
|
||||
return this.lensRepo.findAllForUser(user.id);
|
||||
}
|
||||
|
||||
async findOne(id: number, user: RequestUser) {
|
||||
const [lens] = await this.lensRepo.findById(id);
|
||||
if (!lens) throw new NotFoundException('Lens not found');
|
||||
if (!lens.isPublic && lens.userId !== user.id && !this.isSuperuser(user)) {
|
||||
throw new ForbiddenException('No access to this lens');
|
||||
}
|
||||
return lens;
|
||||
}
|
||||
|
||||
async create(dto: CreateLensDto, user: RequestUser) {
|
||||
const filter = validateGroupRule(dto.filter);
|
||||
const [lens] = await this.lensRepo.insert({
|
||||
userId: user.id,
|
||||
name: dto.name,
|
||||
icon: dto.icon ?? null,
|
||||
filter,
|
||||
defaultSort: (dto.defaultSort as SortSpec[]) ?? [],
|
||||
isPublic: dto.isPublic ?? false,
|
||||
});
|
||||
return lens;
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateLensDto, user: RequestUser) {
|
||||
const [lens] = await this.lensRepo.findById(id);
|
||||
if (!lens) throw new NotFoundException('Lens not found');
|
||||
if (lens.userId !== user.id && !this.isSuperuser(user)) throw new ForbiddenException('Cannot modify this lens');
|
||||
|
||||
const filter = validateGroupRule(dto.filter);
|
||||
const [updated] = await this.lensRepo.update(id, lens.userId, {
|
||||
name: dto.name,
|
||||
icon: dto.icon,
|
||||
filter: filter ?? undefined,
|
||||
defaultSort: dto.defaultSort as SortSpec[] | undefined,
|
||||
isPublic: dto.isPublic,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: number, user: RequestUser) {
|
||||
const [lens] = await this.lensRepo.findById(id);
|
||||
if (!lens) throw new NotFoundException('Lens not found');
|
||||
if (lens.userId !== user.id && !this.isSuperuser(user)) throw new ForbiddenException('Cannot delete this lens');
|
||||
|
||||
await this.lensRepo.delete(id, lens.userId);
|
||||
}
|
||||
|
||||
async executeLens(id: number, user: RequestUser, page: number, size: number): Promise<BooksPage> {
|
||||
const [lens] = await this.lensRepo.findById(id);
|
||||
if (!lens) throw new NotFoundException('Lens not found');
|
||||
if (!lens.isPublic && lens.userId !== user.id && !this.isSuperuser(user)) {
|
||||
throw new ForbiddenException('No access to this lens');
|
||||
}
|
||||
|
||||
const libs = await this.libraryService.findAll(user);
|
||||
const accessibleLibraryIds = libs.map((l) => l.id);
|
||||
|
||||
const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds });
|
||||
const orderBy = this.queryBuilder.buildOrderBy((lens.defaultSort as SortSpec[]) ?? []);
|
||||
const { rows, authorRows, fileRows, total } = await this.bookRepo.findCards({
|
||||
where,
|
||||
orderBy,
|
||||
limit: size,
|
||||
offset: page * size,
|
||||
});
|
||||
|
||||
return { items: this.assembleBookCards(rows, authorRows, fileRows), total, page, size };
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
|
||||
|
||||
import type { BookQuery } from '@projectx/types';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookQueryPipe } from '../book/pipes/book-query.pipe';
|
||||
import { BookService } from '../book/book.service';
|
||||
import { CreateLibraryDto } from './dto/create-library.dto';
|
||||
import { GrantLibraryAccessDto } from './dto/grant-library-access.dto';
|
||||
import { PrescanLibraryDto } from './dto/prescan-library.dto';
|
||||
@@ -13,7 +16,10 @@ import { LibraryService } from './library.service';
|
||||
|
||||
@Controller('libraries')
|
||||
export class LibraryController {
|
||||
constructor(private readonly libraryService: LibraryService) {}
|
||||
constructor(
|
||||
private readonly libraryService: LibraryService,
|
||||
private readonly bookService: BookService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
findAll(@CurrentUser() user: RequestUser) {
|
||||
@@ -26,6 +32,11 @@ export class LibraryController {
|
||||
return this.libraryService.verifyUserAccess(user.id, id, isSuperuser).then(() => this.libraryService.findOne(id));
|
||||
}
|
||||
|
||||
@Post(':id/books')
|
||||
queryBooks(@Param('id', ParseIntPipe) libraryId: number, @Body(BookQueryPipe) query: BookQuery, @CurrentUser() user: RequestUser) {
|
||||
return this.bookService.queryForLibrary(user, libraryId, query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('manage_libraries')
|
||||
create(@Body() dto: CreateLibraryDto) {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
|
||||
import { BookModule } from '../book/book.module';
|
||||
import { ScannerModule } from '../scanner/scanner.module';
|
||||
import { LibraryController } from './library.controller';
|
||||
import { LibraryRepository } from './library.repository';
|
||||
import { LibraryService } from './library.service';
|
||||
|
||||
@Module({
|
||||
imports: [ScannerModule],
|
||||
imports: [ScannerModule, forwardRef(() => BookModule)],
|
||||
controllers: [LibraryController],
|
||||
providers: [LibraryService, LibraryRepository],
|
||||
exports: [LibraryService],
|
||||
exports: [LibraryService, LibraryRepository],
|
||||
})
|
||||
export class LibraryModule {}
|
||||
|
||||
Reference in New Issue
Block a user