mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
refactor(client): lift sidebar to App.vue and add rating sort
This commit is contained in:
+7
-4
@@ -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 />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"> · {{ 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"> · {{ 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }} · {{ 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 }} · {{ 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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user