refactor(client): lift sidebar to App.vue and add rating sort

This commit is contained in:
neon
2026-03-02 19:36:08 -06:00
parent ec5dda57b0
commit 63662b46f8
21 changed files with 900 additions and 882 deletions
+7 -4
View File
@@ -6,6 +6,8 @@ import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
import SettingsDrawer from '@/features/settings/SettingsDrawer.vue'
import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
import { SidebarProvider } from '@/components/ui/sidebar'
import AppSidebar from '@/components/AppSidebar.vue'
useThemeStore()
const { isOpen } = useChangePasswordDialog()
@@ -14,11 +16,12 @@ const route = useRoute()
<template>
<TooltipProvider :delay-duration="0">
<router-view v-slot="{ Component }">
<keep-alive :include="['HomeView', 'LensView']">
<SidebarProvider>
<AppSidebar />
<router-view v-slot="{ Component }">
<component :is="Component" :key="route.path" />
</keep-alive>
</router-view>
</router-view>
</SidebarProvider>
<ChangePasswordDialog v-if="isOpen" />
<SettingsDrawer />
<Toaster rich-colors />
+2 -2
View File
@@ -38,7 +38,7 @@ function getCollectionIcon(iconName: string | null) {
const router = useRouter()
const route = useRoute()
const { libraries, fetchLibraries } = useLibraries()
const { libraries, fetchLibraries, refreshLibraries } = useLibraries()
const { lenses, fetchLenses } = useLenses()
const { collections, fetchCollections } = useCollections()
const { hasPermission } = usePermissions()
@@ -88,7 +88,7 @@ function onLibrarySaved(library: Library) {
createLibraryOpen.value = false
subscribeLibrary(library.id)
scanningLibraryId.value = library.id
fetchLibraries()
refreshLibraries()
}
watch(progressMap, (map) => {
+3 -1
View File
@@ -7,12 +7,14 @@ const coverSize = ref(storage.get('coverSize', 150))
const gridGap = ref(storage.get('gridGap', 16))
const viewMode = ref<'grid' | 'list'>(storage.get('viewMode', 'grid'))
const cardOverlays = ref<CardOverlayKey[]>(storage.get('cardOverlays', ['series', 'progress-bar', 'progress-pill', 'format']))
const lensFilterExpanded = ref(storage.get('lensFilterExpanded', true))
watch(coverSize, (v) => storage.set('coverSize', v))
watch(gridGap, (v) => storage.set('gridGap', v))
watch(viewMode, (v) => storage.set('viewMode', v))
watch(cardOverlays, (v) => storage.set('cardOverlays', v), { deep: true })
watch(lensFilterExpanded, (v) => storage.set('lensFilterExpanded', v))
export function useDisplaySettings() {
return { coverSize, gridGap, viewMode, cardOverlays }
return { coverSize, gridGap, viewMode, cardOverlays, lensFilterExpanded }
}
@@ -9,6 +9,7 @@ export const SORT_FIELD_LABELS: Record<SortField, string> = {
updatedAt: 'Date Updated',
publishedYear: 'Published Year',
pageCount: 'Page Count',
rating: 'Rating',
}
export const FIELD_LABELS: Record<RuleField, string> = {
@@ -3,12 +3,23 @@ import { api } from '@/lib/api'
import type { Collection } from '@projectx/types'
const collections = ref<Collection[]>([])
let loaded = false
let fetchPromise: Promise<void> | null = null
export function useCollections() {
async function fetchCollections() {
const res = await api('/api/v1/collections')
if (!res.ok) throw new Error('Failed to fetch collections')
collections.value = await res.json()
async function fetchCollections(): Promise<void> {
if (loaded) return
if (fetchPromise) return fetchPromise
fetchPromise = api('/api/v1/collections')
.then(async (res) => {
if (!res.ok) return
collections.value = await res.json()
loaded = true
})
.finally(() => {
fetchPromise = null
})
return fetchPromise
}
async function fetchCollectionsWithMembership(bookIds: number[]): Promise<Collection[]> {
@@ -1,4 +1,4 @@
import { computed, ref, watch, type Ref } from 'vue'
import { computed, ref, type Ref } from 'vue'
import { api } from '@/lib/api'
import type { BookCard, BooksPage } from '@projectx/types'
@@ -39,10 +39,5 @@ export function useLens(lensId: Ref<number>) {
}
}
watch(lensId, (id) => {
if (!id || isNaN(id)) return
load(true)
})
return { items, total, loading, hasMore, load }
}
@@ -3,15 +3,22 @@ import { api } from '@/lib/api'
import type { Library } from '@projectx/types'
const libraries = ref<Library[]>([])
let loaded = false
let fetchPromise: Promise<void> | null = null
export function useLibraries() {
async function fetchLibraries(): Promise<void> {
if (loaded) return
return refreshLibraries()
}
async function refreshLibraries(): Promise<void> {
if (fetchPromise) return fetchPromise
fetchPromise = api('/api/v1/libraries')
.then(async (res) => {
if (!res.ok) return
libraries.value = await res.json()
loaded = true
})
.finally(() => {
fetchPromise = null
@@ -19,5 +26,5 @@ export function useLibraries() {
return fetchPromise
}
return { libraries, fetchLibraries }
return { libraries, fetchLibraries, refreshLibraries }
}
@@ -5,7 +5,7 @@ import { useDisplaySettings, type CardOverlayKey } from '@/composables/useDispla
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const themeStore = useThemeStore()
const { coverSize, gridGap, cardOverlays } = useDisplaySettings()
const { coverSize, gridGap, cardOverlays, lensFilterExpanded } = useDisplaySettings()
const OVERLAY_OPTIONS: { key: CardOverlayKey; label: string; hint: string }[] = [
{ key: 'series', label: 'Series name', hint: 'Series title and index badge at top-left' },
@@ -245,4 +245,23 @@ function toggleOverlay(key: CardOverlayKey) {
</div>
</div>
</div>
<!-- Lenses -->
<div class="mt-8">
<p class="settings-group-label">Lenses</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between px-5 py-3.5 bg-card cursor-pointer" @click="lensFilterExpanded = !lensFilterExpanded">
<div>
<p class="settings-label">Show filter preview by default</p>
<p class="settings-hint">Expand the active filter and sort summary when opening a lens</p>
</div>
<div class="w-9 h-5 rounded-full transition-colors shrink-0" :class="lensFilterExpanded ? 'bg-primary' : 'bg-muted'">
<div
class="mt-0.5 size-4 rounded-full bg-white shadow-sm transition-transform"
:class="lensFilterExpanded ? 'translate-x-4' : 'translate-x-0.5'"
/>
</div>
</div>
</div>
</div>
</template>
+166 -171
View File
@@ -12,7 +12,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
const route = useRoute()
const router = useRouter()
const { libraries, fetchLibraries } = useLibraries()
const { libraries, fetchLibraries, refreshLibraries } = useLibraries()
const { subscribeLibrary, getProgress, isScanning, progressMap, getCoverRefreshProgress, isRefreshingCovers } = useScanProgress()
const stats = ref<Record<number, LibraryStats>>({})
@@ -115,13 +115,18 @@ function openEdit(lib: LibraryType) {
creatorOpen.value = true
}
function closeCreator() {
creatorOpen.value = false
editingLibrary.value = null
}
async function onSaved(library: LibraryType) {
const isNew = !editingLibrary.value
creatorOpen.value = false
editingLibrary.value = null
subscribeLibrary(library.id)
if (isNew) pendingNavigateLibraryId.value = library.id
await fetchLibraries()
await refreshLibraries()
loadAllStats()
}
@@ -140,7 +145,7 @@ async function confirmDelete() {
if (res.ok) {
toast.success(`"${deletedName}" deleted`)
deletingLibrary.value = null
await fetchLibraries()
await refreshLibraries()
loadAllStats()
// If currently viewing the deleted library, navigate to the first remaining one
if (route.name === 'library' && Number(route.params.id) === deletedId) {
@@ -195,186 +200,176 @@ function coverRefreshLabel(libraryId: number): string {
</script>
<template>
<!-- Header -->
<div class="flex items-start justify-between mb-8">
<div>
<h2 class="settings-title">Libraries</h2>
<p class="settings-subtitle">Manage your media libraries and trigger content scans.</p>
</div>
<div class="flex items-center gap-2 shrink-0 ml-4">
<button
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
:disabled="scanningAll || libraries.length === 0"
@click="scanAll"
>
<RefreshCw :size="12" :class="scanningAll ? 'animate-spin' : ''" />
{{ scanningAll ? 'Scanning...' : 'Scan All' }}
</button>
<button
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
@click="openCreate"
>
<Plus :size="12" />
Add Library
</button>
</div>
<!-- Header -->
<div class="flex items-start justify-between mb-8">
<div>
<h2 class="settings-title">Libraries</h2>
<p class="settings-subtitle">Manage your media libraries and trigger content scans.</p>
</div>
<div class="flex items-center gap-2 shrink-0 ml-4">
<button
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
:disabled="scanningAll || libraries.length === 0"
@click="scanAll"
>
<RefreshCw :size="12" :class="scanningAll ? 'animate-spin' : ''" />
{{ scanningAll ? 'Scanning...' : 'Scan All' }}
</button>
<button
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
@click="openCreate"
>
<Plus :size="12" />
Add Library
</button>
</div>
</div>
<!-- Library list -->
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div v-for="lib in libraries" :key="lib.id" class="bg-card px-5 py-4">
<div class="flex items-center gap-4">
<RouterLink
:to="{ name: 'library', params: { id: lib.id } }"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors"
>
<FolderOpen :size="17" class="text-primary" />
</RouterLink>
<div class="flex-1 min-w-0">
<RouterLink
:to="{ name: 'library', params: { id: lib.id } }"
class="settings-label hover:text-primary transition-colors truncate block"
>{{ lib.name }}</RouterLink
>
<div class="flex items-center gap-3 mt-0.5">
<span v-if="stats[lib.id]" class="text-xs text-muted-foreground">
{{ stats[lib.id].totalBooks }} book{{ stats[lib.id].totalBooks === 1 ? '' : 's' }}
<span v-if="stats[lib.id].totalSizeBytes > 0"> &middot; {{ formatBytes(stats[lib.id].totalSizeBytes) }}</span>
<!-- Library list -->
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div v-for="lib in libraries" :key="lib.id" class="bg-card px-5 py-4">
<div class="flex items-center gap-4">
<RouterLink
:to="{ name: 'library', params: { id: lib.id } }"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors"
>
<FolderOpen :size="17" class="text-primary" />
</RouterLink>
<div class="flex-1 min-w-0">
<RouterLink :to="{ name: 'library', params: { id: lib.id } }" class="settings-label hover:text-primary transition-colors truncate block">{{
lib.name
}}</RouterLink>
<div class="flex items-center gap-3 mt-0.5">
<span v-if="stats[lib.id]" class="text-xs text-muted-foreground">
{{ stats[lib.id]?.totalBooks }} book{{ stats[lib.id]?.totalBooks === 1 ? '' : 's' }}
<span v-if="(stats[lib.id]?.totalSizeBytes ?? 0) > 0"> &middot; {{ formatBytes(stats[lib.id]?.totalSizeBytes ?? 0) }}</span>
</span>
<span v-else class="text-xs text-muted-foreground">
Added {{ new Date(lib.createdAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) }}
</span>
<span v-if="lib.watch" class="text-xs text-primary/70">Watching</span>
</div>
<!-- Scan progress bar -->
<div v-if="getProgress(lib.id)" class="mt-2">
<div class="flex items-center justify-between mb-1">
<span class="text-xs" :class="getProgress(lib.id)?.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'">
{{ scanProgressLabel(lib.id) }}
</span>
<span v-else class="text-xs text-muted-foreground">
Added {{ new Date(lib.createdAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) }}
</span>
<span v-if="lib.watch" class="text-xs text-primary/70">Watching</span>
</div>
<!-- Scan progress bar -->
<div v-if="getProgress(lib.id)" class="mt-2">
<div class="flex items-center justify-between mb-1">
<span class="text-xs" :class="getProgress(lib.id)?.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'">
{{ scanProgressLabel(lib.id) }}
</span>
</div>
<div v-if="getProgress(lib.id)?.status === 'running'" class="h-1 w-full rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
:style="{
width:
getProgress(lib.id)!.total > 0 ? `${Math.floor((getProgress(lib.id)!.processed / getProgress(lib.id)!.total) * 100)}%` : '100%',
animation: getProgress(lib.id)!.total === 0 ? 'pulse 1.5s ease-in-out infinite' : 'none',
}"
/>
</div>
</div>
<!-- Cover refresh progress bar -->
<div v-if="getCoverRefreshProgress(lib.id)" class="mt-2">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-muted-foreground">{{ coverRefreshLabel(lib.id) }}</span>
</div>
<div v-if="getCoverRefreshProgress(lib.id)?.status === 'running'" class="h-1 w-full rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full bg-accent transition-all duration-300"
:style="{
width:
getCoverRefreshProgress(lib.id)!.total > 0
? `${Math.floor((getCoverRefreshProgress(lib.id)!.processed / getCoverRefreshProgress(lib.id)!.total) * 100)}%`
: '0%',
}"
/>
</div>
<div v-if="getProgress(lib.id)?.status === 'running'" class="h-1 w-full rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
:style="{
width:
getProgress(lib.id)!.total > 0 ? `${Math.floor((getProgress(lib.id)!.processed / getProgress(lib.id)!.total) * 100)}%` : '100%',
animation: getProgress(lib.id)!.total === 0 ? 'pulse 1.5s ease-in-out infinite' : 'none',
}"
/>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<Tooltip>
<TooltipTrigger as-child>
<button
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="openEdit(lib)"
>
<Pencil :size="13" />
</button>
</TooltipTrigger>
<TooltipContent>Edit library</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
@click="openDelete(lib)"
>
<Trash2 :size="13" />
</button>
</TooltipTrigger>
<TooltipContent>Delete library</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
:disabled="isRefreshingCovers(lib.id)"
@click="refreshCovers(lib)"
>
<Images :size="13" :class="isRefreshingCovers(lib.id) ? 'animate-pulse' : ''" />
</button>
</TooltipTrigger>
<TooltipContent>Refresh covers</TooltipContent>
</Tooltip>
<button
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
:disabled="isScanning(lib.id)"
@click="scan(lib)"
>
<RefreshCw :size="12" :class="isScanning(lib.id) ? 'animate-spin' : ''" />
{{ isScanning(lib.id) ? 'Scanning...' : 'Scan' }}
</button>
<!-- Cover refresh progress bar -->
<div v-if="getCoverRefreshProgress(lib.id)" class="mt-2">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-muted-foreground">{{ coverRefreshLabel(lib.id) }}</span>
</div>
<div v-if="getCoverRefreshProgress(lib.id)?.status === 'running'" class="h-1 w-full rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full bg-accent transition-all duration-300"
:style="{
width:
getCoverRefreshProgress(lib.id)!.total > 0
? `${Math.floor((getCoverRefreshProgress(lib.id)!.processed / getCoverRefreshProgress(lib.id)!.total) * 100)}%`
: '0%',
}"
/>
</div>
</div>
</div>
</div>
<div v-if="libraries.length === 0" class="bg-card px-5 py-12 text-center">
<FolderOpen :size="28" class="text-muted-foreground/30 mx-auto mb-3" />
<p class="text-sm text-muted-foreground">No libraries yet.</p>
<button class="mt-3 text-xs text-primary hover:underline" @click="openCreate">Create your first library</button>
</div>
</div>
<!-- Library creator/editor modal -->
<LibraryCreatorModal
v-if="creatorOpen"
:library="editingLibrary"
@close="creatorOpen = false; editingLibrary = null"
@saved="onSaved"
/>
<!-- Delete confirmation dialog -->
<div v-if="deletingLibrary" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div class="bg-card border border-border rounded-xl shadow-xl w-full max-w-sm p-6">
<h3 class="text-base font-semibold text-foreground mb-1">Delete "{{ deletingLibrary.name }}"?</h3>
<p class="text-sm text-muted-foreground mb-4">
This will permanently remove all books, metadata, reading progress, bookmarks, and annotations in this library. This cannot be undone.
</p>
<p class="text-sm text-foreground mb-2">Type the library name to confirm:</p>
<input
v-model="deleteConfirmName"
type="text"
:placeholder="deletingLibrary.name"
class="w-full text-sm border border-border rounded-md px-3 py-2 bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-destructive mb-4"
@keydown.enter="deleteConfirmName === deletingLibrary.name && !deleting ? confirmDelete() : null"
@keydown.escape="deletingLibrary = null"
/>
<div class="flex justify-end gap-2">
<div class="flex items-center gap-1.5 shrink-0">
<Tooltip>
<TooltipTrigger as-child>
<button class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" @click="openEdit(lib)">
<Pencil :size="13" />
</button>
</TooltipTrigger>
<TooltipContent>Edit library</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
@click="openDelete(lib)"
>
<Trash2 :size="13" />
</button>
</TooltipTrigger>
<TooltipContent>Delete library</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
:disabled="isRefreshingCovers(lib.id)"
@click="refreshCovers(lib)"
>
<Images :size="13" :class="isRefreshingCovers(lib.id) ? 'animate-pulse' : ''" />
</button>
</TooltipTrigger>
<TooltipContent>Refresh covers</TooltipContent>
</Tooltip>
<button
class="px-4 py-2 text-sm font-medium rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground"
@click="deletingLibrary = null"
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
:disabled="isScanning(lib.id)"
@click="scan(lib)"
>
Cancel
</button>
<button
class="px-4 py-2 text-sm font-medium rounded-md bg-destructive text-destructive-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
:disabled="deleteConfirmName !== deletingLibrary.name || deleting"
@click="confirmDelete"
>
{{ deleting ? 'Deleting...' : 'Delete Library' }}
<RefreshCw :size="12" :class="isScanning(lib.id) ? 'animate-spin' : ''" />
{{ isScanning(lib.id) ? 'Scanning...' : 'Scan' }}
</button>
</div>
</div>
</div>
<div v-if="libraries.length === 0" class="bg-card px-5 py-12 text-center">
<FolderOpen :size="28" class="text-muted-foreground/30 mx-auto mb-3" />
<p class="text-sm text-muted-foreground">No libraries yet.</p>
<button class="mt-3 text-xs text-primary hover:underline" @click="openCreate">Create your first library</button>
</div>
</div>
<!-- Library creator/editor modal -->
<LibraryCreatorModal v-if="creatorOpen" :library="editingLibrary" @close="closeCreator" @saved="onSaved" />
<!-- Delete confirmation dialog -->
<div v-if="deletingLibrary" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div class="bg-card border border-border rounded-xl shadow-xl w-full max-w-sm p-6">
<h3 class="text-base font-semibold text-foreground mb-1">Delete "{{ deletingLibrary.name }}"?</h3>
<p class="text-sm text-muted-foreground mb-4">
This will permanently remove all books, metadata, reading progress, bookmarks, and annotations in this library. This cannot be undone.
</p>
<p class="text-sm text-foreground mb-2">Type the library name to confirm:</p>
<input
v-model="deleteConfirmName"
type="text"
:placeholder="deletingLibrary.name"
class="w-full text-sm border border-border rounded-md px-3 py-2 bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-destructive mb-4"
@keydown.enter="deleteConfirmName === deletingLibrary.name && !deleting ? confirmDelete() : null"
@keydown.escape="deletingLibrary = null"
/>
<div class="flex justify-end gap-2">
<button
class="px-4 py-2 text-sm font-medium rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground"
@click="deletingLibrary = null"
>
Cancel
</button>
<button
class="px-4 py-2 text-sm font-medium rounded-md bg-destructive text-destructive-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
:disabled="deleteConfirmName !== deletingLibrary.name || deleting"
@click="confirmDelete"
>
{{ deleting ? 'Deleting...' : 'Delete Library' }}
</button>
</div>
</div>
</div>
</template>
@@ -18,7 +18,7 @@ const emit = defineEmits<{
}>()
const router = useRouter()
const { libraries, fetchLibraries } = useLibraries()
const { libraries, fetchLibraries, refreshLibraries } = useLibraries()
const { result, loading, error, finalize, reset } = useStagingFinalize()
const defaultLibraryId = ref<number | null>(null)
@@ -84,7 +84,7 @@ async function start() {
defaultLibraryId: defaultLibraryId.value!,
defaultFolderId: defaultFolderId.value!,
})
if (result.value?.succeeded) fetchLibraries()
if (result.value?.succeeded) refreshLibraries()
}
function handleClose() {
@@ -187,7 +187,12 @@ function goToBook(bookId: number) {
</div>
<div class="max-h-56 overflow-y-auto space-y-1">
<div v-for="r in result.results" :key="r.fileId" class="rounded-lg overflow-hidden" :class="r.success ? '' : r.isDuplicate ? 'bg-amber-500/5' : 'bg-red-500/5'">
<div
v-for="r in result.results"
:key="r.fileId"
class="rounded-lg overflow-hidden"
:class="r.success ? '' : r.isDuplicate ? 'bg-amber-500/5' : 'bg-red-500/5'"
>
<div class="flex items-center gap-2 px-3 py-2 text-sm" :class="r.success ? 'hover:bg-muted/50' : ''">
<Check v-if="r.success" class="size-3.5 text-emerald-600 dark:text-emerald-400 shrink-0" />
<Copy v-else-if="r.isDuplicate" class="size-3.5 text-amber-500 shrink-0" />
@@ -9,6 +9,7 @@ const socketConnected = ref(true)
let socket: Socket | null = null
const changeListeners = new Set<() => void>()
let fetchPromise: Promise<void> | null = null
let loaded = false
async function restFetchSummary() {
if (fetchPromise) return fetchPromise
@@ -49,11 +50,15 @@ function getSocket(): Socket {
export function useStagingSummary() {
async function fetchSummary(): Promise<void> {
if (loaded) return
if (fetchPromise) return fetchPromise
loading.value = true
fetchPromise = api('/api/v1/staging/summary')
.then(async (res) => {
if (res.ok) summary.value = await res.json()
if (res.ok) {
summary.value = await res.json()
loaded = true
}
})
.finally(() => {
loading.value = false
+3 -7
View File
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import AppSidebar from '@/components/AppSidebar.vue'
import { SidebarInset } from '@/components/ui/sidebar'
import AppHeader from '@/components/AppHeader.vue'
import BookDetailTabs from '@/features/book/components/detail/BookDetailTabs.vue'
import DetailsTab from '@/features/book/components/detail/tabs/DetailsTab.vue'
@@ -45,9 +44,7 @@ const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === t
</script>
<template>
<SidebarProvider>
<AppSidebar />
<SidebarInset class="flex flex-col min-h-screen overflow-x-hidden">
<SidebarInset class="flex flex-col min-h-screen overflow-x-hidden">
<AppHeader />
<div class="flex items-center border-b shrink-0 h-11">
<BookDetailTabs :book-id="bookId" />
@@ -79,6 +76,5 @@ const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === t
</div>
</template>
</main>
</SidebarInset>
</SidebarProvider>
</SidebarInset>
</template>
+3 -7
View File
@@ -2,8 +2,7 @@
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { BookDetail } from '@projectx/types'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import AppSidebar from '@/components/AppSidebar.vue'
import { SidebarInset } from '@/components/ui/sidebar'
import AppHeader from '@/components/AppHeader.vue'
import BookDetailTabs from '@/features/book/components/detail/BookDetailTabs.vue'
import EditMetadataTab from '@/features/book/components/detail/tabs/EditMetadataTab.vue'
@@ -31,9 +30,7 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
</script>
<template>
<SidebarProvider>
<AppSidebar />
<SidebarInset class="flex flex-col min-h-screen overflow-x-hidden">
<SidebarInset class="flex flex-col min-h-screen overflow-x-hidden">
<AppHeader />
<div class="flex items-center border-b shrink-0 h-11">
<BookDetailTabs :book-id="bookId" />
@@ -47,6 +44,5 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
<div class="h-9 rounded-md bg-muted animate-pulse" />
</div>
</main>
</SidebarInset>
</SidebarProvider>
</SidebarInset>
</template>
+3 -7
View File
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import AppSidebar from '@/components/AppSidebar.vue'
import { SidebarInset } from '@/components/ui/sidebar'
import AppHeader from '@/components/AppHeader.vue'
import BookDetailTabs from '@/features/book/components/detail/BookDetailTabs.vue'
import FilesTab from '@/features/book/components/detail/tabs/FilesTab.vue'
@@ -22,9 +21,7 @@ const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === t
</script>
<template>
<SidebarProvider>
<AppSidebar />
<SidebarInset class="flex flex-col min-h-screen overflow-x-hidden">
<SidebarInset class="flex flex-col min-h-screen overflow-x-hidden">
<AppHeader />
<div class="flex items-center border-b shrink-0 h-11">
<BookDetailTabs :book-id="bookId" />
@@ -36,6 +33,5 @@ const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === t
<div v-for="i in 3" :key="i" class="h-16 rounded-md bg-muted animate-pulse" />
</div>
</main>
</SidebarInset>
</SidebarProvider>
</SidebarInset>
</template>
+79 -80
View File
@@ -8,11 +8,10 @@ import BookListRow from '@/features/book/components/BookListRow.vue'
import BookQuickView from '@/features/book/components/BookQuickView.vue'
import AppHeader from '@/components/AppHeader.vue'
import ViewHeader from '@/components/ViewHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
import SelectionActionBar from '@/components/SelectionActionBar.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import EditCollectionDialog from '@/features/collection/components/EditCollectionDialog.vue'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import { SidebarInset } from '@/components/ui/sidebar'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import { useCollections } from '@/features/collection/composables/useCollections'
@@ -123,12 +122,16 @@ onMounted(async () => {
onUnmounted(() => observer?.disconnect())
watch(collectionId, () => load(true))
watch(loading, (isLoading) => {
if (!isLoading && sentinel.value) {
const rect = sentinel.value.getBoundingClientRect()
if (rect.top < window.innerHeight + 300) load()
}
}, { flush: 'post' })
watch(
loading,
(isLoading) => {
if (!isLoading && sentinel.value) {
const rect = sentinel.value.getBoundingClientRect()
if (rect.top < window.innerHeight + 300) load()
}
},
{ flush: 'post' },
)
</script>
<template>
@@ -158,80 +161,76 @@ watch(loading, (isLoading) => {
<EditCollectionDialog v-if="collection" :open="editCollectionOpen" :collection="collection" @close="editCollectionOpen = false" />
<SidebarProvider>
<AppSidebar />
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
<AppHeader />
<ViewHeader
:title="collection?.name ?? 'Collection'"
:icon="collection?.icon || 'FolderOpen'"
:total="total"
:loaded="books.length"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
v-model:viewMode="viewMode"
:selection-mode="selectionMode"
@toggle-selection="toggleSelectionMode"
>
<template #toolbar>
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="collection"
class="h-8 w-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="editCollectionOpen = true"
>
<Pencil :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Edit collection</TooltipContent>
</Tooltip>
</template>
</ViewHeader>
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
<AppHeader />
<ViewHeader
:title="collection?.name ?? 'Collection'"
:icon="collection?.icon || 'FolderOpen'"
:total="total"
:loaded="books.length"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
v-model:viewMode="viewMode"
:selection-mode="selectionMode"
@toggle-selection="toggleSelectionMode"
<main class="flex-1 overflow-y-auto px-4 py-4" :class="backgroundClass">
<div v-if="!loading && books.length === 0" class="flex flex-col items-center justify-center py-24 gap-3 text-center">
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<FolderOpen :size="28" class="text-muted-foreground/50" />
</div>
<p class="text-sm font-medium text-foreground">No books in this collection</p>
<p class="text-xs text-muted-foreground">Select books from your library and add them here.</p>
</div>
<div
v-show="viewMode === 'grid' && books.length > 0"
class="grid"
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))`, gap: `${gridGap}px` }"
>
<template #toolbar>
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="collection"
class="h-8 w-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="editCollectionOpen = true"
>
<Pencil :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Edit collection</TooltipContent>
</Tooltip>
</template>
</ViewHeader>
<BookCoverCard
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="handleSelect(book.id, $event)"
/>
</div>
<main class="flex-1 overflow-y-auto px-4 py-4" :class="backgroundClass">
<div v-if="!loading && books.length === 0" class="flex flex-col items-center justify-center py-24 gap-3 text-center">
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<FolderOpen :size="28" class="text-muted-foreground/50" />
</div>
<p class="text-sm font-medium text-foreground">No books in this collection</p>
<p class="text-xs text-muted-foreground">Select books from your library and add them here.</p>
</div>
<div v-show="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<BookListRow
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="handleSelect(book.id, $event)"
/>
</div>
<div
v-show="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"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="handleSelect(book.id, $event)"
/>
</div>
<div v-show="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<BookListRow
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="handleSelect(book.id, $event)"
/>
</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>
<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>
</template>
+34 -42
View File
@@ -3,20 +3,16 @@ import { computed, ref } from 'vue'
import { Settings2 } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
import AppHeader from '@/components/AppHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
import { useAuth } from '@/features/auth/composables/useAuth'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import DashboardScroller from '@/features/dashboard/components/DashboardScroller.vue'
import DashboardSettingsSheet from '@/features/dashboard/components/DashboardSettingsSheet.vue'
import { useDashboardConfig } from '@/features/dashboard/composables/useDashboardConfig'
import { usePermissions } from '@/features/auth/composables/usePermissions'
const { user } = useAuth()
const { scrollers } = useDashboardConfig()
const themeStore = useThemeStore()
const { hasPermission } = usePermissions()
const settingsOpen = ref(false)
@@ -33,47 +29,43 @@ const greeting = computed(() => {
</script>
<template>
<SidebarProvider>
<AppSidebar />
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
<AppHeader />
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
<AppHeader />
<main class="flex-1 overflow-y-auto" :class="backgroundClass">
<!-- Greeting -->
<div class="flex items-center justify-between px-6 pb-4 pt-6">
<h1 class="text-xl font-semibold tracking-tight">{{ greeting }}</h1>
<Tooltip>
<TooltipTrigger as-child>
<button
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="settingsOpen = true"
>
<Settings2 :size="16" />
</button>
</TooltipTrigger>
<TooltipContent>Customize dashboard</TooltipContent>
</Tooltip>
</div>
<main class="flex-1 overflow-y-auto" :class="backgroundClass">
<!-- Greeting -->
<div class="flex items-center justify-between px-6 pb-4 pt-6">
<h1 class="text-xl font-semibold tracking-tight">{{ greeting }}</h1>
<Tooltip>
<TooltipTrigger as-child>
<button
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="settingsOpen = true"
>
<Settings2 :size="16" />
</button>
</TooltipTrigger>
<TooltipContent>Customize dashboard</TooltipContent>
</Tooltip>
<!-- Scrollers -->
<div class="space-y-5 px-4 pb-8 sm:px-6">
<DashboardScroller
v-for="scroller in enabledScrollers"
:key="`${scroller.id}-${scroller.type}-${scroller.lensId ?? 0}`"
:type="scroller.type"
:title="scroller.label"
:limit="scroller.limit"
:lens-id="scroller.lensId"
/>
<div v-if="enabledScrollers.length === 0" class="px-2 py-12 text-center">
<p class="text-sm text-muted-foreground">All shelves are hidden.</p>
<button class="mt-2 text-sm text-primary hover:underline" @click="settingsOpen = true">Customize dashboard</button>
</div>
<!-- Scrollers -->
<div class="space-y-5 px-4 pb-8 sm:px-6">
<DashboardScroller
v-for="scroller in enabledScrollers"
:key="`${scroller.id}-${scroller.type}-${scroller.lensId ?? 0}`"
:type="scroller.type"
:title="scroller.label"
:limit="scroller.limit"
:lens-id="scroller.lensId"
/>
<div v-if="enabledScrollers.length === 0" class="px-2 py-12 text-center">
<p class="text-sm text-muted-foreground">All shelves are hidden.</p>
<button class="mt-2 text-sm text-primary hover:underline" @click="settingsOpen = true">Customize dashboard</button>
</div>
</div>
</main>
</SidebarInset>
</SidebarProvider>
</div>
</main>
</SidebarInset>
<DashboardSettingsSheet v-model:open="settingsOpen" />
</template>
+158 -163
View File
@@ -10,12 +10,11 @@ import BookSortBuilder from '@/features/book/components/BookSortBuilder.vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import AppHeader from '@/components/AppHeader.vue'
import ViewHeader from '@/components/ViewHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
import SelectionActionBar from '@/components/SelectionActionBar.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import SaveAsLensDialog from '@/features/lens/components/SaveAsLensDialog.vue'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import { SidebarInset } from '@/components/ui/sidebar'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import { useBookQuery, type BookCard } from '@/features/book/composables/useBookQuery'
@@ -322,181 +321,177 @@ function handleBookAction(book: BookCard, action: BookActionType) {
</script>
<template>
<SidebarProvider>
<AppSidebar />
<SidebarInset class="flex flex-col h-screen glow-wrapper">
<AppHeader />
<ViewHeader
:title="title"
:total="total"
:loaded="books.length"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
v-model:viewMode="viewMode"
:selection-mode="selectionMode"
@toggle-selection="toggleSelectionMode"
>
<template #toolbar>
<div class="flex items-center gap-1">
<Popover>
<PopoverTrigger as-child>
<button
class="flex items-center gap-1.5 h-8 px-3 rounded-md border text-sm transition-colors"
:class="
!isDefaultSort
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
<SidebarInset class="flex flex-col h-screen glow-wrapper">
<AppHeader />
<ViewHeader
:title="title"
:total="total"
:loaded="books.length"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
v-model:viewMode="viewMode"
:selection-mode="selectionMode"
@toggle-selection="toggleSelectionMode"
>
<template #toolbar>
<div class="flex items-center gap-1">
<Popover>
<PopoverTrigger as-child>
<button
class="flex items-center gap-1.5 h-8 px-3 rounded-md border text-sm transition-colors"
:class="
!isDefaultSort
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
>
<ArrowUpDown :size="13" />
<span class="hidden lg:inline">{{ sortSummary }}</span>
<span class="lg:hidden"
>Sort<template v-if="!isDefaultSort"> ({{ sort.length }})</template></span
>
<ArrowUpDown :size="13" />
<span class="hidden lg:inline">{{ sortSummary }}</span>
<span class="lg:hidden"
>Sort<template v-if="!isDefaultSort"> ({{ sort.length }})</template></span
>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-80 p-3">
<BookSortBuilder v-model="sortModel" />
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="!isDefaultSort"
@click="resetSort"
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"
>
<X :size="13" />
</button>
</TooltipTrigger>
<TooltipContent>Reset sort to default</TooltipContent>
</Tooltip>
</div>
<div class="w-px h-5 bg-border shrink-0" />
<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>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-80 p-3">
<BookSortBuilder v-model="sortModel" />
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger as-child>
<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"
v-if="!isDefaultSort"
@click="resetSort"
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"
>
<X :size="13" />
Clear
</button>
</TooltipTrigger>
<TooltipContent>Clear all filters</TooltipContent>
<TooltipContent>Reset sort to default</TooltipContent>
</Tooltip>
</template>
</ViewHeader>
<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>
<!-- Filter builder panel -->
<div v-if="filterOpen" class="mb-4 p-3 rounded-md border border-border bg-card">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-medium text-muted-foreground">Filter rules</span>
<div class="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="activeFilterCount > 0"
@click="saveAsLensOpen = true"
class="flex items-center gap-1.5 h-7 px-3 rounded-md border border-input text-xs font-medium text-muted-foreground bg-background hover:text-foreground hover:bg-muted transition-colors"
>
<Telescope :size="13" />
Save as Lens
</button>
</TooltipTrigger>
<TooltipContent>Save this filter as a named lens</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="activeFilterCount > 0"
@click="saveFilter"
class="flex items-center gap-1.5 h-7 px-3 rounded-md border text-xs font-medium transition-colors"
:class="
isFilterSaved
? 'border-primary/40 text-primary bg-primary/8'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
>
<BookmarkCheck v-if="isFilterSaved" :size="13" />
<Bookmark v-else :size="13" />
{{ isFilterSaved ? 'Saved' : 'Save filter' }}
</button>
</TooltipTrigger>
<TooltipContent>{{ isFilterSaved ? 'Filter saved' : 'Save filter for this library' }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="hasSavedFilter"
@click="forgetSavedFilter"
class="h-6 w-6 flex items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<X :size="11" />
</button>
</TooltipTrigger>
<TooltipContent>Remove saved filter</TooltipContent>
</Tooltip>
</div>
</div>
<BookFilterBuilder v-model="filter" />
</div>
<!-- Grid view -->
<div
v-show="viewMode === 'grid'"
class="grid"
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))`, gap: `${gridGap}px` }"
<div class="w-px h-5 bg-border shrink-0" />
<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'
"
>
<BookCoverCard
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="handleSelect(book.id, $event)"
/>
</div>
<Filter :size="13" />
<span>Filters</span>
<span v-if="activeFilterCount > 0" class="text-xs font-semibold">({{ activeFilterCount }})</span>
</button>
<Tooltip>
<TooltipTrigger as-child>
<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"
>
<X :size="13" />
Clear
</button>
</TooltipTrigger>
<TooltipContent>Clear all filters</TooltipContent>
</Tooltip>
</template>
</ViewHeader>
<!-- List view -->
<div v-show="viewMode === 'list'" class="flex flex-col divide-y divide-border">
<BookListRow
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="handleSelect(book.id, $event)"
/>
</div>
<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>
<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>
<!-- Filter builder panel -->
<div v-if="filterOpen" class="mb-4 p-3 rounded-md border border-border bg-card">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-medium text-muted-foreground">Filter rules</span>
<div class="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="activeFilterCount > 0"
@click="saveAsLensOpen = true"
class="flex items-center gap-1.5 h-7 px-3 rounded-md border border-input text-xs font-medium text-muted-foreground bg-background hover:text-foreground hover:bg-muted transition-colors"
>
<Telescope :size="13" />
Save as Lens
</button>
</TooltipTrigger>
<TooltipContent>Save this filter as a named lens</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="activeFilterCount > 0"
@click="saveFilter"
class="flex items-center gap-1.5 h-7 px-3 rounded-md border text-xs font-medium transition-colors"
:class="
isFilterSaved
? 'border-primary/40 text-primary bg-primary/8'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
>
<BookmarkCheck v-if="isFilterSaved" :size="13" />
<Bookmark v-else :size="13" />
{{ isFilterSaved ? 'Saved' : 'Save filter' }}
</button>
</TooltipTrigger>
<TooltipContent>{{ isFilterSaved ? 'Filter saved' : 'Save filter for this library' }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
v-if="hasSavedFilter"
@click="forgetSavedFilter"
class="h-6 w-6 flex items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<X :size="11" />
</button>
</TooltipTrigger>
<TooltipContent>Remove saved filter</TooltipContent>
</Tooltip>
</div>
</div>
</main>
</SidebarInset>
</SidebarProvider>
<BookFilterBuilder v-model="filter" />
</div>
<!-- Grid view -->
<div
v-show="viewMode === 'grid'"
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"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="handleSelect(book.id, $event)"
/>
</div>
<!-- List view -->
<div v-show="viewMode === 'list'" class="flex flex-col divide-y divide-border">
<BookListRow
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="handleSelect(book.id, $event)"
/>
</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>
<BookQuickView
:book-id="quickViewBookId"
+142 -138
View File
@@ -7,11 +7,10 @@ import BookListRow from '@/features/book/components/BookListRow.vue'
import BookQuickView from '@/features/book/components/BookQuickView.vue'
import AppHeader from '@/components/AppHeader.vue'
import ViewHeader from '@/components/ViewHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
import LensEditorPanel from '@/features/lens/components/LensEditorPanel.vue'
import SelectionActionBar from '@/components/SelectionActionBar.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import { SidebarInset } from '@/components/ui/sidebar'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import { useLens } from '@/features/lens/composables/useLens'
@@ -27,7 +26,7 @@ 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 { coverSize, gridGap, viewMode, lensFilterExpanded } = useDisplaySettings()
const lensId = computed(() => Number(route.params.id))
@@ -42,8 +41,7 @@ const sortChip = computed(() => {
return specs.map((s) => `${SORT_FIELD_LABELS[s.field as SortField] ?? s.field} ${s.dir === 'asc' ? '↑' : '↓'}`).join(', ')
})
const filterExpanded = ref(true)
const filterExpanded = lensFilterExpanded
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleBook, rangeSelectTo, isSelected } = useBookSelection()
@@ -120,6 +118,11 @@ async function handleDelete() {
}
}
function openEditor() {
editorOpen.value = true
confirmDelete.value = false
}
function onSaved() {
load(true)
}
@@ -135,12 +138,6 @@ function checkSentinel() {
}
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()
@@ -148,14 +145,24 @@ onMounted(async () => {
{ rootMargin: '300px' },
)
if (sentinel.value) observer.observe(sentinel.value)
await fetchLenses()
if (!lens.value) {
router.push({ name: 'home' })
return
}
load(true)
})
onUnmounted(() => observer?.disconnect())
watch(lensId, () => load(true))
watch(loading, (isLoading) => {
if (!isLoading) checkSentinel()
}, { flush: 'post' })
watch(
loading,
(isLoading) => {
if (!isLoading) checkSentinel()
},
{ flush: 'post' },
)
</script>
<template>
@@ -183,131 +190,128 @@ watch(loading, (isLoading) => {
@added="exitSelectionMode"
/>
<SidebarProvider>
<AppSidebar />
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
<AppHeader />
<ViewHeader
:title="lens?.name ?? 'Lens'"
:icon="lens?.icon ?? undefined"
:total="total"
:loaded="books.length"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
v-model:viewMode="viewMode"
:selection-mode="selectionMode"
@toggle-selection="toggleSelectionMode"
>
<template #actions>
<button
v-if="lens?.filter || sortChip"
@click="filterExpanded = !filterExpanded"
class="hidden md:flex items-center gap-1.5 h-8 px-3 rounded-md border border-input text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
:title="filterExpanded ? 'Hide filter' : 'Show filter'"
>
<component :is="filterExpanded ? ChevronUp : ChevronDown" :size="13" />
<span>Filter</span>
</button>
<button
@click="editorOpen = true; confirmDelete = false"
class="hidden md:flex items-center gap-1.5 h-8 px-3 rounded-md border border-input text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<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>
</ViewHeader>
<main class="flex-1 overflow-y-auto px-4 py-4" :class="backgroundClass">
<!-- Filter summary -->
<div v-if="filterExpanded && (lens?.filter || sortChip)" class="flex flex-wrap items-center gap-2 mb-4 cursor-pointer" @click="editorOpen = true">
<FilterSummary v-if="lens?.filter" :node="(lens.filter as GroupRule)" />
<span
v-if="sortChip"
class="inline-flex items-center text-xs rounded-md border border-border/60 overflow-hidden"
>
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-muted text-muted-foreground border-r border-border/60">
<ArrowUpDown :size="10" class="shrink-0" />
<span class="font-semibold">Sort</span>
</span>
<span class="px-2 py-0.5 bg-muted/40 text-foreground font-medium">{{ sortChip }}</span>
</span>
</div>
<!-- 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-show="viewMode === 'grid' && books.length > 0"
class="grid"
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))`, gap: `${gridGap}px` }"
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
<AppHeader />
<ViewHeader
:title="lens?.name ?? 'Lens'"
:icon="lens?.icon ?? undefined"
:total="total"
:loaded="books.length"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
v-model:viewMode="viewMode"
:selection-mode="selectionMode"
@toggle-selection="toggleSelectionMode"
>
<template #actions>
<button
v-if="lens?.filter || sortChip"
@click="filterExpanded = !filterExpanded"
class="hidden md:flex items-center gap-1.5 h-8 px-3 rounded-md border border-input text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
:title="filterExpanded ? 'Hide filter' : 'Show filter'"
>
<BookCoverCard
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@select="handleSelect(book.id, $event)"
/>
</div>
<component :is="filterExpanded ? ChevronUp : ChevronDown" :size="13" />
<span>Filter</span>
</button>
<button
@click="openEditor"
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>
</ViewHeader>
<!-- List view -->
<div v-show="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<BookListRow
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@select="handleSelect(book.id, $event)"
@action="handleBookAction(book, $event)"
/>
</div>
<main class="flex-1 overflow-y-auto px-4 py-4" :class="backgroundClass">
<!-- Filter summary -->
<div
v-if="filterExpanded && (lens?.filter || sortChip)"
class="flex flex-wrap items-center gap-2 mb-4 cursor-pointer"
@click="editorOpen = true"
>
<FilterSummary v-if="lens?.filter" :node="lens.filter as GroupRule" />
<span v-if="sortChip" class="inline-flex items-center text-xs rounded-md border border-border/60 overflow-hidden">
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-muted text-muted-foreground border-r border-border/60">
<ArrowUpDown :size="10" class="shrink-0" />
<span class="font-semibold">Sort</span>
</span>
<span class="px-2 py-0.5 bg-muted/40 text-foreground font-medium">{{ sortChip }}</span>
</span>
</div>
<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>
<!-- 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>
</main>
</SidebarInset>
</SidebarProvider>
<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-show="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"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@select="handleSelect(book.id, $event)"
/>
</div>
<!-- List view -->
<div v-show="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<BookListRow
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@select="handleSelect(book.id, $event)"
@action="handleBookAction(book, $event)"
/>
</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>
</template>
+131 -136
View File
@@ -4,9 +4,8 @@ import { PackageOpen, CheckCircle2, AlertCircle } from 'lucide-vue-next'
import type { StagingFile } from '@projectx/types'
import { api } from '@/lib/api'
import { formatBytes } from '@/lib/formatting'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import AppHeader from '@/components/AppHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
import { SidebarInset } from '@/components/ui/sidebar'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import { useStagingFiles } from '@/features/staging/composables/useStagingFiles'
import { useStagingSummary } from '@/features/staging/composables/useStagingSummary'
@@ -258,150 +257,146 @@ onUnmounted(() => {
</script>
<template>
<SidebarProvider>
<AppSidebar />
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
<AppHeader />
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
<AppHeader />
<main
class="flex-1 overflow-y-auto"
:class="backgroundClass"
@dragover="onDragOver"
@dragenter="onDragEnter"
@dragleave="onDragLeave"
@drop="onDrop"
>
<div class="flex flex-col gap-4 p-4 sm:p-6 max-w-6xl mx-auto w-full">
<div class="flex items-center gap-2.5">
<div class="flex items-center justify-center size-9 rounded-lg bg-primary/10">
<PackageOpen class="size-4.5 text-primary" />
</div>
<h1 class="text-xl font-semibold text-foreground tracking-tight">Staging</h1>
<main
class="flex-1 overflow-y-auto"
:class="backgroundClass"
@dragover="onDragOver"
@dragenter="onDragEnter"
@dragleave="onDragLeave"
@drop="onDrop"
>
<div class="flex flex-col gap-4 p-4 sm:p-6 max-w-6xl mx-auto w-full">
<div class="flex items-center gap-2.5">
<div class="flex items-center justify-center size-9 rounded-lg bg-primary/10">
<PackageOpen class="size-4.5 text-primary" />
</div>
<h1 class="text-xl font-semibold text-foreground tracking-tight">Staging</h1>
<span
v-if="summary.total > 0"
class="ml-1 inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/15 text-primary text-xs font-semibold tabular-nums"
>
{{ summary.total }}
</span>
<Transition name="fade">
<span
v-if="summary.total > 0"
class="ml-1 inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/15 text-primary text-xs font-semibold tabular-nums"
v-if="newFilesDetected"
class="ml-2 inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 text-xs font-medium"
>
{{ summary.total }}
<span class="size-1.5 rounded-full bg-current animate-pulse" />
New files detected
</span>
<Transition name="fade">
<span
v-if="newFilesDetected"
class="ml-2 inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 text-xs font-medium"
>
<span class="size-1.5 rounded-full bg-current animate-pulse" />
New files detected
</span>
</Transition>
<Transition name="fade">
<span
v-if="applyFetchedResult"
class="ml-2 inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400 text-xs font-medium"
>
<CheckCircle2 class="size-3.5" />
{{
applyFetchedResult.applied === 0
? 'No fetched metadata to apply'
: `Applied to ${applyFetchedResult.applied} file${applyFetchedResult.applied !== 1 ? 's' : ''}${applyFetchedResult.skippedEdited > 0 ? `, skipped ${applyFetchedResult.skippedEdited} with manual edits` : ''}`
}}
</span>
</Transition>
<Transition name="fade">
<span
v-if="retryQueued !== null"
class="ml-2 inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-primary/15 text-primary text-xs font-medium"
>
<CheckCircle2 class="size-3.5" />
{{ retryQueued === 0 ? 'No error files to retry' : `Retrying ${retryQueued} file${retryQueued !== 1 ? 's' : ''}` }}
</span>
</Transition>
<Transition name="fade">
<span
v-if="!socketConnected"
class="ml-2 inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-muted text-muted-foreground text-xs font-medium"
>
<span class="size-1.5 rounded-full bg-muted-foreground animate-pulse" />
Reconnecting
</span>
</Transition>
<Transition name="fade">
<span
v-if="rescanFailed"
class="ml-2 inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-red-500/15 text-red-600 dark:text-red-400 text-xs font-medium"
>
<AlertCircle class="size-3.5" />
Rescan failed
</span>
</Transition>
</div>
<StagingToolbar
:active-status="filters.status"
:selection-count="selectionCount"
:has-selection="hasSelection"
:fetched-count="fetchedCount"
:error-count="errorCount"
@status-filter="setStatus"
@search="setSearch"
@rescan="refresh"
@rescan-error="handleRescanError"
@retry-fetch="handleRetryFetch"
@bulk-discard="handleBulkDiscard"
@finalize="openFinalize"
@bulk-edit="openBulkEdit"
@refresh="refresh"
@apply-fetched="handleApplyFetched"
/>
<!-- Statistics bar -->
<div v-if="statistics && statistics.byFormat.length > 0" class="flex items-center gap-3 flex-wrap text-xs text-muted-foreground">
<span class="font-medium text-foreground">{{ formatBytes(statistics.totalSizeBytes) }} staged</span>
</Transition>
<Transition name="fade">
<span
v-for="f in statistics.byFormat"
:key="f.format"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-muted/50 tabular-nums"
v-if="applyFetchedResult"
class="ml-2 inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400 text-xs font-medium"
>
<span class="uppercase font-medium text-foreground/70">{{ f.format }}</span>
{{ f.count }} &middot; {{ formatBytes(f.sizeBytes) }}
<CheckCircle2 class="size-3.5" />
{{
applyFetchedResult.applied === 0
? 'No fetched metadata to apply'
: `Applied to ${applyFetchedResult.applied} file${applyFetchedResult.applied !== 1 ? 's' : ''}${applyFetchedResult.skippedEdited > 0 ? `, skipped ${applyFetchedResult.skippedEdited} with manual edits` : ''}`
}}
</span>
</div>
<!-- Drag-and-drop overlay -->
<div v-if="dragOver" class="fixed inset-0 z-40 flex items-center justify-center bg-primary/5 backdrop-blur-sm pointer-events-none">
<div class="flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-primary/40 bg-card/80 px-12 py-10 shadow-lg">
<PackageOpen class="size-10 text-primary" />
<p class="text-sm font-medium text-foreground">Drop files to stage</p>
<p class="text-xs text-muted-foreground">Supported: {{ SUPPORTED_FORMATS.join(', ') }}</p>
</div>
</div>
<StagingFileList
:items="items"
:loading="loading"
:is-selected="isSelected"
:select-all="selectAll"
:empty-message="emptyMessage"
@select="toggleSelect"
@select-all="toggleSelectAll"
@open="openSheet"
@apply-fetched="handleInlineApplyFetched"
/>
<div v-if="pageCount > 1" class="flex items-center justify-center gap-1">
<button
v-for="p in pageCount"
:key="p"
class="size-8 rounded-lg text-xs font-medium transition-all active:scale-95"
:class="filters.page === p ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'"
@click="setPage(p)"
</Transition>
<Transition name="fade">
<span
v-if="retryQueued !== null"
class="ml-2 inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-primary/15 text-primary text-xs font-medium"
>
{{ p }}
</button>
<CheckCircle2 class="size-3.5" />
{{ retryQueued === 0 ? 'No error files to retry' : `Retrying ${retryQueued} file${retryQueued !== 1 ? 's' : ''}` }}
</span>
</Transition>
<Transition name="fade">
<span
v-if="!socketConnected"
class="ml-2 inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-muted text-muted-foreground text-xs font-medium"
>
<span class="size-1.5 rounded-full bg-muted-foreground animate-pulse" />
Reconnecting
</span>
</Transition>
<Transition name="fade">
<span
v-if="rescanFailed"
class="ml-2 inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-red-500/15 text-red-600 dark:text-red-400 text-xs font-medium"
>
<AlertCircle class="size-3.5" />
Rescan failed
</span>
</Transition>
</div>
<StagingToolbar
:active-status="filters.status"
:selection-count="selectionCount"
:has-selection="hasSelection"
:fetched-count="fetchedCount"
:error-count="errorCount"
@status-filter="setStatus"
@search="setSearch"
@rescan="refresh"
@rescan-error="handleRescanError"
@retry-fetch="handleRetryFetch"
@bulk-discard="handleBulkDiscard"
@finalize="openFinalize"
@bulk-edit="openBulkEdit"
@refresh="refresh"
@apply-fetched="handleApplyFetched"
/>
<!-- Statistics bar -->
<div v-if="statistics && statistics.byFormat.length > 0" class="flex items-center gap-3 flex-wrap text-xs text-muted-foreground">
<span class="font-medium text-foreground">{{ formatBytes(statistics.totalSizeBytes) }} staged</span>
<span
v-for="f in statistics.byFormat"
:key="f.format"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-muted/50 tabular-nums"
>
<span class="uppercase font-medium text-foreground/70">{{ f.format }}</span>
{{ f.count }} &middot; {{ formatBytes(f.sizeBytes) }}
</span>
</div>
<!-- Drag-and-drop overlay -->
<div v-if="dragOver" class="fixed inset-0 z-40 flex items-center justify-center bg-primary/5 backdrop-blur-sm pointer-events-none">
<div class="flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-primary/40 bg-card/80 px-12 py-10 shadow-lg">
<PackageOpen class="size-10 text-primary" />
<p class="text-sm font-medium text-foreground">Drop files to stage</p>
<p class="text-xs text-muted-foreground">Supported: {{ SUPPORTED_FORMATS.join(', ') }}</p>
</div>
</div>
</main>
</SidebarInset>
</SidebarProvider>
<StagingFileList
:items="items"
:loading="loading"
:is-selected="isSelected"
:select-all="selectAll"
:empty-message="emptyMessage"
@select="toggleSelect"
@select-all="toggleSelectAll"
@open="openSheet"
@apply-fetched="handleInlineApplyFetched"
/>
<div v-if="pageCount > 1" class="flex items-center justify-center gap-1">
<button
v-for="p in pageCount"
:key="p"
class="size-8 rounded-lg text-xs font-medium transition-all active:scale-95"
:class="filters.page === p ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'"
@click="setPage(p)"
>
{{ p }}
</button>
</div>
</div>
</main>
</SidebarInset>
<Teleport to="body">
<StagingFileSheet v-if="selectedFile" :file="selectedFile" @close="closeSheet" @discarded="onDiscarded" @updated="onFileUpdated" />
+106 -106
View File
@@ -1,124 +1,124 @@
export type RuleField =
| 'title'
| 'publisher'
| 'language'
| 'series'
| 'seriesIndex'
| 'publishedYear'
| 'pageCount'
| 'author'
| 'genre'
| 'tag'
| 'collection'
| 'format'
| 'addedAt'
| 'fileAvailability'
| 'rating'
| 'readProgress'
| 'description'
| 'isbn'
| "title"
| "publisher"
| "language"
| "series"
| "seriesIndex"
| "publishedYear"
| "pageCount"
| "author"
| "genre"
| "tag"
| "collection"
| "format"
| "addedAt"
| "fileAvailability"
| "rating"
| "readProgress"
| "description"
| "isbn";
export type RuleOperator =
| 'contains'
| 'notContains'
| 'startsWith'
| 'endsWith'
| 'eq'
| 'notEq'
| 'isEmpty'
| 'isNotEmpty'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'between'
| 'includesAny'
| 'includesAll'
| 'excludesAll'
| 'before'
| 'after'
| 'withinLast'
| 'isMissing'
| 'isPresent'
| 'isUnread'
| 'isInProgress'
| 'isFinished'
| "contains"
| "notContains"
| "startsWith"
| "endsWith"
| "eq"
| "notEq"
| "isEmpty"
| "isNotEmpty"
| "gt"
| "gte"
| "lt"
| "lte"
| "between"
| "includesAny"
| "includesAll"
| "excludesAll"
| "before"
| "after"
| "withinLast"
| "isMissing"
| "isPresent"
| "isUnread"
| "isInProgress"
| "isFinished";
export const FIELD_OPERATORS: Record<RuleField, RuleOperator[]> = {
title: ['contains', 'notContains', 'startsWith', 'endsWith', 'eq', 'notEq', 'isEmpty', 'isNotEmpty'],
publisher: ['contains', 'notContains', 'eq', 'notEq', 'includesAny', 'excludesAll', 'isEmpty', 'isNotEmpty'],
language: ['eq', 'notEq', 'includesAny', 'excludesAll', 'isEmpty', 'isNotEmpty'],
series: ['contains', 'notContains', 'eq', 'notEq', 'includesAny', 'excludesAll', 'isEmpty', 'isNotEmpty'],
author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
genre: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
tag: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
collection: ['includesAny', 'excludesAll', 'isEmpty', 'isNotEmpty'],
format: ['includesAny', 'excludesAll'],
publishedYear: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
pageCount: ['gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
addedAt: ['before', 'after', 'between', 'withinLast'],
fileAvailability: ['isMissing', 'isPresent'],
rating: ['eq', 'gt', 'gte', 'lt', 'lte', 'isEmpty', 'isNotEmpty'],
readProgress: ['isUnread', 'isInProgress', 'isFinished'],
description: ['isEmpty', 'isNotEmpty'],
isbn: ['isEmpty', 'isNotEmpty', 'eq'],
}
title: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"],
publisher: ["contains", "notContains", "eq", "notEq", "includesAny", "excludesAll", "isEmpty", "isNotEmpty"],
language: ["eq", "notEq", "includesAny", "excludesAll", "isEmpty", "isNotEmpty"],
series: ["contains", "notContains", "eq", "notEq", "includesAny", "excludesAll", "isEmpty", "isNotEmpty"],
author: ["includesAny", "includesAll", "excludesAll", "isEmpty", "isNotEmpty"],
genre: ["includesAny", "includesAll", "excludesAll", "isEmpty", "isNotEmpty"],
tag: ["includesAny", "includesAll", "excludesAll", "isEmpty", "isNotEmpty"],
collection: ["includesAny", "excludesAll", "isEmpty", "isNotEmpty"],
format: ["includesAny", "excludesAll"],
publishedYear: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
seriesIndex: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
pageCount: ["gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
addedAt: ["before", "after", "between", "withinLast"],
fileAvailability: ["isMissing", "isPresent"],
rating: ["eq", "gt", "gte", "lt", "lte", "isEmpty", "isNotEmpty"],
readProgress: ["isUnread", "isInProgress", "isFinished"],
description: ["isEmpty", "isNotEmpty"],
isbn: ["isEmpty", "isNotEmpty", "eq"],
};
export const RULE_FIELDS = Object.keys(FIELD_OPERATORS) as RuleField[]
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',
'isMissing',
'isPresent',
'isUnread',
'isInProgress',
'isFinished',
]
"contains",
"notContains",
"startsWith",
"endsWith",
"eq",
"notEq",
"isEmpty",
"isNotEmpty",
"gt",
"gte",
"lt",
"lte",
"between",
"includesAny",
"includesAll",
"excludesAll",
"before",
"after",
"withinLast",
"isMissing",
"isPresent",
"isUnread",
"isInProgress",
"isFinished",
];
export type Rule = {
type: 'rule'
field: RuleField
operator: RuleOperator
value?: string | number | string[] | number[]
valueTo?: string | number
}
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)[]
}
type: "group";
join: "AND" | "OR";
rules: (Rule | GroupRule)[];
};
export type SortField = 'author' | 'title' | 'series' | 'seriesIndex' | 'addedAt' | 'updatedAt' | 'publishedYear' | 'pageCount'
export type SortField = "author" | "title" | "series" | "seriesIndex" | "addedAt" | "updatedAt" | "publishedYear" | "pageCount" | "rating";
export const SORT_FIELDS: SortField[] = ['author', 'title', 'series', 'seriesIndex', 'addedAt', 'updatedAt', 'publishedYear', 'pageCount']
export const SORT_FIELDS: SortField[] = ["author", "title", "series", "seriesIndex", "addedAt", "updatedAt", "publishedYear", "pageCount", "rating"];
export type SortSpec = {
field: SortField
dir: 'asc' | 'desc'
}
field: SortField;
dir: "asc" | "desc";
};
export type BookQuery = {
filter?: GroupRule
sort: SortSpec[]
pagination: { page: number; size: number }
}
filter?: GroupRule;
sort: SortSpec[];
pagination: { page: number; size: number };
};
@@ -30,6 +30,7 @@ const SORT_FIELD_MAP: Partial<Record<SortField, AnyColumn>> = {
updatedAt: books.updatedAt,
publishedYear: bookMetadata.publishedYear,
pageCount: bookMetadata.pageCount,
rating: bookMetadata.rating,
};
@Injectable()
@@ -69,14 +70,15 @@ export class BookQueryBuilder {
),
);
} else {
const col = SORT_FIELD_MAP[field]!;
const col = SORT_FIELD_MAP[field];
if (!col) continue;
result.push(sql`${col} ${sql.raw(D)} NULLS LAST`);
if (field === 'seriesIndex' && !sort.some((s) => s.field === 'series')) {
result.push(sql`${bookMetadata.seriesName} ${sql.raw(D)} NULLS LAST`);
}
}
}
return result;
return result.length > 0 ? result : [sql`${bookMetadata.title} ASC NULLS LAST`];
}
private groupToSql(node: GroupRule, depth: number, userId?: number): SQL {