feat(books): add lens with query engine, filter builder, and icon picker

This commit is contained in:
neon
2026-01-21 18:07:07 -06:00
parent 5e1c211067
commit bc1ee837bf
43 changed files with 2828 additions and 2318 deletions
+2
View File
@@ -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" />
+61 -4
View File
@@ -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">
+209
View File
@@ -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 }
}
+5
View File
@@ -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',
+96 -30
View File
@@ -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>
+231
View File
@@ -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>