fix(client): improve virtual grid layout stability and alignment

This commit is contained in:
neon
2026-03-16 22:42:52 -06:00
parent 4b6f276070
commit 3c27802be2
7 changed files with 482 additions and 367 deletions
+1 -3
View File
@@ -24,15 +24,13 @@ const viewKey = computed(() => {
<template>
<SidebarProvider class="glow-wrapper min-h-screen" :class="backgroundClass">
<AppSidebar />
<SidebarInset class="flex flex-col h-screen overflow-hidden relative bg-transparent">
<SidebarInset class="flex flex-col h-screen overflow-hidden relative bg-transparent md:pb-3">
<!-- 1. Global App Header: Fixed at the top, independent of views -->
<AppHeader />
<!-- 2. Independent View Area: Everything below the header scrolls here -->
<div class="px-4 pt-2 flex-1 overflow-y-auto overflow-x-hidden relative scroll-smooth bg-transparent">
<router-view :key="viewKey" />
<!-- Bottom gutter matching the top floating margin -->
<div class="h-6 w-full" />
</div>
</SidebarInset>
<BookMetadataFetchWidget />
+1 -3
View File
@@ -42,9 +42,7 @@ function getIconComponent(name: string) {
</script>
<template>
<div
class="flex h-10 shrink-0 items-center gap-2 p-2 mr-4 mt-2 ml-0 mb-2 sticky top-0 z-20 transition-all duration-300 bg-background/80 backdrop-blur-md"
>
<div class="sticky top-0 z-20 mb-2 mt-2 flex h-10 shrink-0 items-center gap-2 p-2 transition-all duration-300 bg-background/80 backdrop-blur-md">
<!-- Left: optional icon + title + count -->
<div class="flex items-center gap-2 flex-1 min-w-0">
<component v-if="icon" :is="getIconComponent(icon)" :size="16" class="shrink-0 text-muted-foreground/90" />
@@ -5,7 +5,7 @@ import { ChevronLeft } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { AuthorSummary, BookCard } from '@projectx/types'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
import BookListRow from '@/features/book/components/BookListRow.vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
@@ -242,7 +242,7 @@ onMounted(async () => {
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !loadingBooks.value) {
if (entries[0]?.isIntersecting && !loadingBooks.value && hasMore.value) {
void loadBooks()
}
},
@@ -290,7 +290,7 @@ watch(authorName, () => {
</script>
<template>
<main class="flex-none pr-2">
<main class="flex min-h-full flex-col pr-2">
<div class="mb-3 mt-2 mr-4 flex items-center gap-2 px-1">
<button
class="inline-flex h-8 items-center gap-1 rounded-md border border-input px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@@ -431,15 +431,15 @@ watch(authorName, () => {
<p class="text-xs text-muted-foreground">Try changing sort/order or selecting another library.</p>
</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" />
</div>
<VirtualBookGrid
v-if="viewMode === 'grid' && books.length > 0"
:books="books"
:cover-size="coverSize"
:grid-gap="gridGap"
@action="handleBookAction"
/>
<div v-show="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<div v-if="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
<BookListRow v-for="book in books" :key="book.id" :book="book" @action="handleBookAction(book, $event)" />
</div>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useElementSize, useWindowSize } from '@vueuse/core'
import { RecycleScroller } from 'vue-virtual-scroller'
import type { BookCard } from '@projectx/types'
import BookCoverCard from './BookCoverCard.vue'
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
const props = withDefaults(
defineProps<{
books: BookCard[]
coverSize: number
gridGap: number
selectionMode?: boolean
isSelected?: (bookId: number) => boolean
}>(),
{
selectionMode: false,
isSelected: undefined,
},
)
const emit = defineEmits<{
action: [book: BookCard, action: BookActionType]
select: [bookId: number, event: MouseEvent]
}>()
const containerRef = ref<HTMLElement | null>(null)
const { width: containerWidth } = useElementSize(containerRef)
const { width: windowWidth } = useWindowSize()
function asPositiveInt(value: unknown, fallback: number) {
const n = Number(value)
if (!Number.isFinite(n) || n <= 0) return fallback
return Math.round(n)
}
const coverPx = computed(() => asPositiveInt(props.coverSize, 140))
const gapPx = computed(() => asPositiveInt(props.gridGap, 20))
const availableWidth = computed(() => {
const observed = Number(containerWidth.value)
if (Number.isFinite(observed) && observed > 0) return Math.round(observed)
const direct = Number(containerRef.value?.getBoundingClientRect().width ?? 0)
if (Number.isFinite(direct) && direct > 0) return Math.round(direct)
const parent = Number(containerRef.value?.parentElement?.getBoundingClientRect().width ?? 0)
if (Number.isFinite(parent) && parent > 0) return Math.round(parent)
const viewport = Number(windowWidth.value)
if (Number.isFinite(viewport) && viewport > 0) return Math.round(Math.max(viewport - 48, 0))
return coverPx.value + gapPx.value
})
const targetCellSize = computed(() => coverPx.value + gapPx.value)
const gridItems = computed(() => {
const cols = Math.floor((availableWidth.value + gapPx.value) / targetCellSize.value)
return Number.isFinite(cols) && cols > 0 ? cols : 1
})
const itemSecondarySize = computed(() => {
return Math.max(1, Math.floor((availableWidth.value + gapPx.value) / gridItems.value))
})
const cardWidth = computed(() => Math.max(1, itemSecondarySize.value - gapPx.value))
const cardHeight = computed(() => Math.max(1, Math.round((cardWidth.value * 3) / 2)))
const itemSize = computed(() => cardHeight.value + gapPx.value)
const buffer = computed(() => Math.max(itemSize.value * 2, 240))
const scrollerStyle = computed(() => ({
'--book-grid-gap': `${gapPx.value}px`,
'--book-grid-height': `${cardHeight.value}px`,
}))
</script>
<template>
<div ref="containerRef" class="w-full">
<RecycleScroller
:items="books"
key-field="id"
page-mode
:item-size="itemSize"
:grid-items="gridItems"
:item-secondary-size="itemSecondarySize"
:buffer="buffer"
:style="scrollerStyle"
class="book-grid-scroller"
>
<template #default="{ item: book }">
<div class="book-grid-cell">
<BookCoverCard
:book="book"
:selection-mode="selectionMode"
:selected="isSelected?.(book.id) ?? false"
@action="emit('action', book, $event)"
@select="emit('select', book.id, $event)"
/>
</div>
</template>
</RecycleScroller>
</div>
</template>
<style>
@import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
</style>
<style scoped>
.book-grid-scroller {
width: 100%;
}
.book-grid-cell {
height: var(--book-grid-height);
box-sizing: border-box;
padding-left: 0;
padding-right: var(--book-grid-gap);
padding-bottom: var(--book-grid-gap);
}
</style>
+65 -68
View File
@@ -3,7 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { FolderOpen, Pencil } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
import BookListRow from '@/features/book/components/BookListRow.vue'
import BookQuickView from '@/features/book/components/BookQuickView.vue'
import ViewHeader from '@/components/ViewHeader.vue'
@@ -129,7 +129,7 @@ onMounted(async () => {
load(true)
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !loading.value) load()
if (entries[0]?.isIntersecting && !loading.value && hasMore.value) load()
},
{ rootMargin: '300px' },
)
@@ -142,7 +142,7 @@ watch(collectionId, () => load(true))
watch(
loading,
(isLoading) => {
if (!isLoading && sentinel.value) {
if (!isLoading && hasMore.value && sentinel.value) {
const rect = sentinel.value.getBoundingClientRect()
if (rect.top < window.innerHeight + 300) load()
}
@@ -184,72 +184,69 @@ watch(
<SendBookDialog :open="sendBookOpen" :book-ids="[...selectedIds]" @update:open="sendBookOpen = $event" @sent="exitSelectionMode" />
<DeleteBookDialog :open="deleteBookId !== null" :deleting="deletingBook" @confirm="confirmDelete" @cancel="cancelDelete" />
<ViewHeader
:title="collection?.name ?? 'Collection'"
:icon="collection?.icon || 'FolderOpen'"
:total="total"
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>
<main class="flex-none pr-2">
<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` }"
<section class="flex min-h-full flex-col">
<ViewHeader
:title="collection?.name ?? 'Collection'"
:icon="collection?.icon || 'FolderOpen'"
:total="total"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
v-model:viewMode="viewMode"
:selection-mode="selectionMode"
@toggle-selection="toggleSelectionMode"
>
<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>
<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>
<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>
<main class="flex-1 min-h-0">
<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 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>
<VirtualBookGrid
v-if="viewMode === 'grid' && books.length > 0"
:books="books"
:cover-size="coverSize"
:grid-gap="gridGap"
:selection-mode="selectionMode"
:is-selected="isSelected"
@action="handleBookAction"
@select="handleSelect"
/>
<div v-if="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>
</section>
</template>
+163 -166
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowUpDown, Bookmark, BookmarkCheck, BookOpen, Filter, Telescope, X } from 'lucide-vue-next'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
import BookListRow from '@/features/book/components/BookListRow.vue'
import BookQuickView from '@/features/book/components/BookQuickView.vue'
import BookFilterBuilder from '@/features/book/components/BookFilterBuilder.vue'
@@ -192,7 +192,7 @@ onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !loading.value) load()
if (entries[0]?.isIntersecting && !loading.value && hasMore.value) load()
},
{ rootMargin: '300px' },
)
@@ -284,192 +284,189 @@ function handleBookAction(book: BookCard, action: BookActionType) {
</script>
<template>
<ViewHeader
:title="title"
:icon="libraryIcon"
:total="total"
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
>
</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>
<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>
<main class="flex-none pr-2">
<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>
<section class="flex min-h-full flex-col">
<ViewHeader
:title="title"
:icon="libraryIcon"
:total="total"
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
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="flex items-center gap-1.5 h-8 px-3 rounded-md border text-sm transition-colors"
:class="
isFilterSaved
? 'border-primary/40 text-primary bg-primary/8'
!isDefaultSort
? 'border-primary text-primary bg-primary/10'
: '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' }}
<ArrowUpDown :size="13" />
<span class="hidden lg:inline">{{ sortSummary }}</span>
<span class="lg:hidden"
>Sort<template v-if="!isDefaultSort"> ({{ sort.length }})</template></span
>
</button>
</TooltipTrigger>
<TooltipContent>{{ isFilterSaved ? 'Filter saved' : 'Save filter for this library' }}</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent align="start" class="w-80 p-3">
<BookSortBuilder v-model="sortModel" />
</PopoverContent>
</Popover>
<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"
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="11" />
<X :size="13" />
</button>
</TooltipTrigger>
<TooltipContent>Remove saved filter</TooltipContent>
<TooltipContent>Reset sort to default</TooltipContent>
</Tooltip>
</div>
</div>
<BookFilterBuilder v-model="filter" />
</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>
<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>
<!-- Empty state: no matches with filters -->
<div v-if="!loading && books.length === 0 && activeFilterCount > 0" 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 your filters</p>
<p class="text-xs text-muted-foreground">Try adjusting or clearing your filters to see more books.</p>
<button @click="clearFilters" class="text-xs text-primary hover:underline">Clear filters</button>
</div>
<main class="flex-1 min-h-0">
<div v-if="error" class="text-sm text-destructive mb-4">{{ error }}</div>
<!-- Empty state: no books in library -->
<div v-else-if="!loading && 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">
<BookOpen :size="28" class="text-muted-foreground/50" />
<!-- 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>
<div class="flex flex-col gap-1">
<p class="text-sm font-medium text-foreground">Your library is empty</p>
<p class="text-xs text-muted-foreground max-w-xs">Once you add books to this library, they will appear here.</p>
</div>
</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"
<!-- Empty state: no matches with filters -->
<div v-if="!loading && books.length === 0 && activeFilterCount > 0" 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 your filters</p>
<p class="text-xs text-muted-foreground">Try adjusting or clearing your filters to see more books.</p>
<button @click="clearFilters" class="text-xs text-primary hover:underline">Clear filters</button>
</div>
<!-- Empty state: no books in library -->
<div v-else-if="!loading && 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">
<BookOpen :size="28" class="text-muted-foreground/50" />
</div>
<div class="flex flex-col gap-1">
<p class="text-sm font-medium text-foreground">Your library is empty</p>
<p class="text-xs text-muted-foreground max-w-xs">Once you add books to this library, they will appear here.</p>
</div>
</div>
<!-- Grid view -->
<VirtualBookGrid
v-if="viewMode === 'grid' && books.length > 0"
:books="books"
:cover-size="coverSize"
:grid-gap="gridGap"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@action="handleBookAction(book, $event)"
@select="handleSelect(book.id, $event)"
:is-selected="isSelected"
@action="handleBookAction"
@select="handleSelect"
/>
</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)"
@action="handleBookAction(book, $event)"
@select="handleSelect(book.id, $event)"
/>
</div>
<!-- List view -->
<div v-if="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>
<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>
</section>
<BookQuickView
:book-id="quickViewBookId"
+120 -116
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Settings2, Trash2, ChevronDown, ChevronUp, ArrowUpDown, Aperture } from 'lucide-vue-next'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
import BookListRow from '@/features/book/components/BookListRow.vue'
import BookQuickView from '@/features/book/components/BookQuickView.vue'
import ViewHeader from '@/components/ViewHeader.vue'
@@ -159,7 +159,7 @@ function checkSentinel() {
onMounted(async () => {
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !loading.value) load()
if (entries[0]?.isIntersecting && !loading.value && hasMore.value) load()
},
{ rootMargin: '300px' },
)
@@ -216,121 +216,125 @@ watch(
<DeleteBookDialog :open="deleteBookId !== null" :deleting="deletingBook" @confirm="confirmDelete" @cancel="cancelDelete" />
<ViewHeader
:title="lens?.name ?? 'Lens'"
:icon="lens?.icon ?? undefined"
:total="total"
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="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="
confirmLensDelete
? '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>{{ confirmLensDelete ? 'Confirm?' : 'Delete' }}</span>
</button>
</template>
</ViewHeader>
<main class="flex-none pr-2">
<!-- 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">
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Aperture :size="28" class="text-muted-foreground/50" />
</div>
<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` }"
<section class="flex min-h-full flex-col">
<ViewHeader
:title="lens?.name ?? 'Lens'"
:icon="lens?.icon ?? undefined"
:total="total"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
v-model:viewMode="viewMode"
:selection-mode="selectionMode"
@toggle-selection="toggleSelectionMode"
>
<BookCoverCard
v-for="book in books"
:key="book.id"
:book="book"
:selection-mode="selectionMode"
:selected="isSelected(book.id)"
@select="handleSelect(book.id, $event)"
/>
</div>
<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="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="
confirmLensDelete
? '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>{{ confirmLensDelete ? '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 min-h-0">
<!-- 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>
</div>
</main>
<!-- 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">
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Aperture :size="28" class="text-muted-foreground/50" />
</div>
<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 -->
<VirtualBookGrid
v-if="viewMode === 'grid' && books.length > 0"
:books="books"
:cover-size="coverSize"
:grid-gap="gridGap"
:selection-mode="selectionMode"
:is-selected="isSelected"
@action="handleBookAction"
@select="handleSelect"
/>
<!-- List view -->
<div v-if="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>
</section>
</template>