mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(client): improve virtual grid layout stability and alignment
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user