feat(books): add quick view panel and proportional cover card overlay

This commit is contained in:
neon
2026-01-24 15:08:33 -06:00
parent 1562f730d2
commit 4effca0813
7 changed files with 460 additions and 65 deletions
@@ -3,11 +3,16 @@ import type { BookCard, BookFileRef } from '@projectx/types'
import { bookCoverStyle } from '../lib/book-cover'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2 } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
const router = useRouter()
const props = defineProps<{ book: BookCard }>()
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
const emit = defineEmits<{ action: [type: BookActionType] }>()
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
const authorLine = computed(() => props.book.authors.join(', ') || null)
const seriesLine = computed(() => {
@@ -17,8 +22,7 @@ const seriesLine = computed(() => {
})
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
// Only show format chips when there are multiple files
const extraFiles = computed(() => (props.book.files.length > 1 ? props.book.files : []))
const extraFiles = computed(() => props.book.files.filter((f) => f !== primaryFile.value))
const coverLoaded = ref(false)
const coverFailed = ref(false)
@@ -34,7 +38,7 @@ function openFile(file: BookFileRef) {
<template>
<div
class="group flex flex-col gap-1.5"
class="group flex flex-col @container"
:class="primaryFile ? 'cursor-pointer' : 'cursor-default opacity-60'"
@click="primaryFile && openFile(primaryFile)"
>
@@ -56,7 +60,7 @@ function openFile(file: BookFileRef) {
/>
<!-- Series badge -->
<div v-if="seriesLine" class="absolute top-1.5 left-1.5 right-1.5">
<div v-if="seriesLine" class="absolute top-1.5 left-1.5 right-1.5 z-10">
<span
class="text-[8px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-black/30 backdrop-blur-sm line-clamp-1"
:style="{ color: coverStyle.color }"
@@ -65,28 +69,14 @@ function openFile(file: BookFileRef) {
</span>
</div>
<!-- Format chips — only shown when book has multiple files -->
<div v-if="extraFiles.length > 0" class="absolute bottom-1.5 right-1.5 flex flex-col gap-1 items-end" @click.stop>
<button
v-for="file in extraFiles"
:key="file.id"
class="text-[9px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-black/50 backdrop-blur-sm hover:bg-black/70 transition-colors"
:class="file.role === 'primary' ? 'text-white' : 'text-white/70'"
:title="`Open as ${file.format?.toUpperCase() ?? 'unknown'}`"
@click="openFile(file)"
>
{{ file.format ?? '?' }}
</button>
</div>
<!-- Missing overlay -->
<div v-if="book.status === 'missing'" class="absolute inset-0 bg-black/60 flex items-center justify-center">
<div v-if="book.status === 'missing'" class="absolute inset-0 bg-black/60 flex items-center justify-center z-10">
<span class="text-[10px] font-semibold uppercase tracking-widest text-destructive-foreground bg-destructive px-2 py-0.5 rounded">
Missing
</span>
</div>
<!-- Title + author (shown when no real cover) -->
<!-- Title + author (no-cover fallback, always visible when cover absent) -->
<div v-if="!coverLoaded" class="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
<p class="text-xs font-bold leading-tight line-clamp-3" :style="{ color: coverStyle.color }">
{{ book.title ?? '-' }}
@@ -95,16 +85,72 @@ function openFile(file: BookFileRef) {
{{ authorLine }}
</p>
</div>
</div>
<!-- Text below -->
<div class="px-0.5">
<p class="text-xs font-medium text-foreground truncate leading-tight">
{{ book.title ?? '-' }}
</p>
<p v-if="authorLine" class="text-[11px] text-muted-foreground truncate">
{{ authorLine }}
</p>
<!-- Hover overlay -->
<div
class="absolute inset-0 flex flex-col p-2 bg-black/60 backdrop-blur-[1px] opacity-0 group-hover:opacity-100 transition-opacity duration-150"
@click.stop
>
<!-- Center: primary action buttons -->
<div class="flex-1 flex flex-col items-center justify-center gap-[18cqi]">
<button
v-if="primaryFile"
class="p-[7cqi] rounded-full bg-primary/50 hover:bg-primary transition-colors text-white"
title="Read"
@click="openFile(primaryFile)"
>
<BookOpen class="size-[14cqi]" />
</button>
<button
class="p-[7cqi] rounded-full bg-primary/70 hover:bg-primary transition-colors text-white"
title="Quick view"
@click="emit('action', 'quick-view')"
>
<PanelRight class="size-[14cqi]" />
</button>
</div>
<!-- Bottom: title/author + kebab -->
<div class="flex items-end justify-between gap-2">
<div v-if="coverLoaded" class="min-w-0 flex-1">
<p class="text-xs font-semibold text-white leading-tight line-clamp-2">{{ book.title ?? '-' }}</p>
<p v-if="authorLine" class="text-[10px] text-white/70 truncate mt-0.5">{{ authorLine }}</p>
</div>
<div v-else class="flex-1" />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="p-1.5 rounded-full bg-black/40 hover:bg-white/20 transition-colors text-white shrink-0">
<MoreHorizontal class="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem v-if="primaryFile" @click="openFile(primaryFile)">
<BookOpen class="size-4 mr-2" />
{{ extraFiles.length > 0 ? `Open as ${primaryFile.format?.toUpperCase() ?? 'primary'}` : 'Open' }}
</DropdownMenuItem>
<DropdownMenuItem v-for="file in extraFiles" :key="file.id" @click="openFile(file)">
<BookOpen class="size-4 mr-2" />
Open as {{ file.format?.toUpperCase() ?? '?' }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="emit('action', 'edit-metadata')">
<Pencil class="size-4 mr-2" />
Edit Metadata
</DropdownMenuItem>
<DropdownMenuItem @click="emit('action', 'add-to-collection')">
<FolderPlus class="size-4 mr-2" />
Add to Collection
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="emit('action', 'delete')">
<Trash2 class="size-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
</template>
@@ -3,11 +3,16 @@ import type { BookCard, BookFileRef } from '@projectx/types'
import { bookCoverStyle } from '../lib/book-cover'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2 } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
const router = useRouter()
const props = defineProps<{ book: BookCard }>()
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
const emit = defineEmits<{ action: [type: BookActionType] }>()
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
const authorLine = computed(() => props.book.authors.join(', ') || null)
const seriesLine = computed(() => {
@@ -58,24 +63,62 @@ function openFile(file: BookFileRef) {
<span v-if="seriesLine" class="text-xs text-muted-foreground/70 truncate italic">{{ seriesLine }}</span>
</div>
<!-- Right badges -->
<div class="flex flex-col items-end gap-1 shrink-0" @click.stop>
<span
v-if="book.status === 'missing'"
class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-destructive text-destructive-foreground"
>
Missing
</span>
<!-- Right badges + actions -->
<div class="flex items-center gap-2 shrink-0" @click.stop>
<div class="flex flex-col items-end gap-1">
<span
v-if="book.status === 'missing'"
class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-destructive text-destructive-foreground"
>
Missing
</span>
<button
v-for="file in extraFiles"
:key="file.id"
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/70 transition-colors"
:class="file.role === 'primary' ? 'text-foreground' : 'text-muted-foreground'"
:title="`Open as ${file.format?.toUpperCase() ?? 'unknown'}`"
@click="openFile(file)"
>
{{ file.format ?? '?' }}
</button>
</div>
<button
v-for="file in extraFiles"
:key="file.id"
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/70 transition-colors"
:class="file.role === 'primary' ? 'text-foreground' : 'text-muted-foreground'"
:title="`Open as ${file.format?.toUpperCase() ?? 'unknown'}`"
@click="openFile(file)"
class="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
title="Quick view"
@click="emit('action', 'quick-view')"
>
{{ file.format ?? '?' }}
<PanelRight class="size-4" />
</button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground">
<MoreHorizontal class="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="primaryFile && openFile(primaryFile)">
<BookOpen class="size-4 mr-2" />
Open
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="emit('action', 'edit-metadata')">
<Pencil class="size-4 mr-2" />
Edit Metadata
</DropdownMenuItem>
<DropdownMenuItem @click="emit('action', 'add-to-collection')">
<FolderPlus class="size-4 mr-2" />
Add to Collection
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="emit('action', 'delete')">
<Trash2 class="size-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</template>
@@ -0,0 +1,232 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, FolderPlus, Pencil, Trash2, X } from 'lucide-vue-next'
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { DialogRoot, DialogContent, DialogPortal, DialogOverlay, DialogClose } from 'reka-ui'
import { useBookDetail } from '../composables/useBookDetail'
import { bookCoverStyle } from '../lib/book-cover'
const props = defineProps<{ bookId: number | null; open: boolean }>()
const emit = defineEmits<{
'update:open': [value: boolean]
action: [type: 'edit-metadata' | 'add-to-collection' | 'delete']
}>()
const router = useRouter()
const { detail, loading, fetch } = useBookDetail()
const coverLoaded = ref(false)
const coverFailed = ref(false)
watch(
() => props.bookId,
(id) => {
if (id !== null) {
coverLoaded.value = false
coverFailed.value = false
descriptionExpanded.value = false
fetch(id)
}
},
{ immediate: true },
)
const coverStyle = computed(() => (detail.value ? bookCoverStyle(detail.value.title ?? String(detail.value.id)) : {}))
const seriesLine = computed(() => {
if (!detail.value?.seriesName) return null
const idx = detail.value.seriesIndex
return idx != null ? `${detail.value.seriesName} #${idx % 1 === 0 ? Math.floor(idx) : idx}` : detail.value.seriesName
})
const authorLine = computed(() => detail.value?.authors.map((a) => a.name).join(', ') ?? null)
const descriptionExpanded = ref(false)
const coverLightboxOpen = ref(false)
const primaryFile = computed(() => detail.value?.files.find((f) => f.role === 'primary') ?? detail.value?.files[0] ?? null)
function openBook() {
if (!primaryFile.value || !detail.value) return
router.push({
name: 'reader',
params: { bookId: detail.value.id, fileId: primaryFile.value.id },
query: { format: primaryFile.value.format ?? 'epub' },
})
emit('update:open', false)
}
</script>
<template>
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
<SheetContent side="right" class="sm:max-w-[400px] p-0 overflow-hidden">
<div class="flex flex-col h-full">
<!-- Header: cover + title block -->
<div class="p-5 pt-10 border-b shrink-0">
<div v-if="loading" class="flex gap-4 items-start">
<Skeleton class="w-24 rounded shrink-0" style="aspect-ratio: 2/3" />
<div class="flex-1 space-y-2 pt-1">
<Skeleton class="h-4 w-full" />
<Skeleton class="h-3 w-3/4" />
<Skeleton class="h-3 w-1/2" />
</div>
</div>
<div v-else-if="detail" class="flex gap-4 items-start">
<!-- Cover -->
<div
class="w-24 shrink-0 rounded overflow-hidden shadow-md cursor-zoom-in"
style="aspect-ratio: 2/3"
:style="coverLoaded ? {} : coverStyle"
@click="coverLoaded && !coverFailed && (coverLightboxOpen = true)"
>
<img
v-if="!coverFailed"
:src="`/api/books/${detail.id}/cover`"
class="w-full h-full object-cover transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
:alt="detail.title ?? ''"
@load="coverLoaded = true"
@error="coverFailed = true"
/>
</div>
<!-- Info -->
<div class="flex-1 min-w-0 pr-2">
<SheetTitle class="text-sm font-bold leading-snug line-clamp-3">
{{ detail.title ?? 'Untitled' }}
</SheetTitle>
<p v-if="detail.subtitle" class="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{{ detail.subtitle }}
</p>
<p v-if="authorLine" class="text-xs text-foreground/80 mt-2">{{ authorLine }}</p>
<p v-if="seriesLine" class="text-xs text-muted-foreground mt-0.5 italic">{{ seriesLine }}</p>
</div>
</div>
</div>
<!-- Body: scrollable meta + description -->
<div class="flex-1 overflow-y-auto p-5 space-y-4">
<template v-if="loading">
<div class="flex gap-1.5">
<Skeleton class="h-5 w-12 rounded" />
<Skeleton class="h-5 w-16 rounded" />
<Skeleton class="h-5 w-10 rounded" />
</div>
<Skeleton class="h-3 w-1/2" />
<Skeleton class="h-32 w-full rounded" />
</template>
<template v-else-if="detail">
<!-- Meta chips -->
<div class="flex flex-wrap gap-1.5">
<span
v-for="file in detail.files"
:key="file.id"
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-muted text-muted-foreground"
>
{{ file.format ?? '?' }}
</span>
<span v-if="detail.pageCount" class="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted text-muted-foreground">
{{ detail.pageCount }} pages
</span>
<span v-if="detail.publishedYear" class="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted text-muted-foreground">
{{ detail.publishedYear }}
</span>
<span v-if="detail.language" class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-muted text-muted-foreground">
{{ detail.language }}
</span>
</div>
<!-- Publisher -->
<p v-if="detail.publisher" class="text-xs text-muted-foreground">
{{ detail.publisher }}
</p>
<!-- Tags -->
<div v-if="detail.tags.length" class="flex flex-wrap gap-1.5">
<span v-for="tag in detail.tags" :key="tag" class="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{{ tag }}
</span>
</div>
<!-- Description -->
<div class="border-t pt-4">
<div v-if="detail.description">
<p class="text-sm leading-relaxed text-foreground/80 transition-all" :class="descriptionExpanded ? '' : 'line-clamp-4'">
{{ detail.description }}
</p>
<button
class="text-xs text-muted-foreground hover:text-foreground mt-1.5 transition-colors"
@click="descriptionExpanded = !descriptionExpanded"
>
{{ descriptionExpanded ? 'Show less' : 'Show more' }}
</button>
</div>
<p v-else class="text-xs text-muted-foreground italic">No description available.</p>
</div>
</template>
</div>
<!-- Footer: actions -->
<div class="p-4 border-t shrink-0 flex gap-2">
<button
class="flex flex-1 items-center justify-center gap-2 h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="!primaryFile"
@click="openBook"
>
<BookOpen class="size-4" />
Read
</button>
<button
class="flex items-center justify-center gap-1.5 h-9 px-3 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
title="Edit Metadata"
@click="emit('action', 'edit-metadata')"
>
<Pencil class="size-3.5" />
</button>
<button
class="flex items-center justify-center gap-1.5 h-9 px-3 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
title="Add to Collection"
@click="emit('action', 'add-to-collection')"
>
<FolderPlus class="size-3.5" />
</button>
<button
class="flex items-center justify-center h-9 px-3 rounded-md text-sm text-destructive hover:bg-destructive/10 transition-colors"
title="Delete"
@click="emit('action', 'delete')"
>
<Trash2 class="size-3.5" />
</button>
</div>
</div>
</SheetContent>
</Sheet>
<!-- Cover lightbox -->
<DialogRoot :open="coverLightboxOpen" @update:open="coverLightboxOpen = $event">
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogContent
class="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 max-w-[90vw] max-h-[90vh] outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
>
<img
v-if="detail"
:src="`/api/books/${detail.id}/cover`"
:alt="detail.title ?? ''"
class="max-w-[90vw] max-h-[90vh] rounded-md shadow-2xl object-contain"
/>
<DialogClose
class="absolute -top-3 -right-3 p-1 rounded-full bg-background border border-border text-muted-foreground hover:text-foreground transition-colors"
>
<X class="size-4" />
</DialogClose>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -0,0 +1,26 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { BookDetail } from '@projectx/types'
export function useBookDetail() {
const detail = ref<BookDetail | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function fetch(bookId: number) {
loading.value = true
error.value = null
detail.value = null
try {
const res = await api(`/api/books/${bookId}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
detail.value = await res.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load book'
} finally {
loading.value = false
}
}
return { detail, loading, error, fetch }
}
+25 -3
View File
@@ -4,13 +4,14 @@ import { useRoute, useRouter } from 'vue-router'
import { ArrowDownAZ, ArrowUpAZ, Filter, X } from 'lucide-vue-next'
import BookCoverCard from '@/features/book/components/BookCoverCard.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'
import AppHeader from '@/components/AppHeader.vue'
import ViewHeader from '@/components/ViewHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
import SettingsDrawer from '@/features/settings/SettingsDrawer.vue'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import { useBookQuery } from '@/features/book/composables/useBookQuery'
import { useBookQuery, type BookCard } from '@/features/book/composables/useBookQuery'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
@@ -103,6 +104,20 @@ watch(filter, () => load(true), { deep: true })
watch(loading, (isLoading) => {
if (!isLoading) loadIfSentinelVisible()
})
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
const quickViewBookId = ref<number | null>(null)
const quickViewOpen = ref(false)
function handleBookAction(book: BookCard, action: BookActionType) {
if (action === 'quick-view') {
quickViewBookId.value = book.id
quickViewOpen.value = true
return
}
// TODO: implement remaining actions
}
</script>
<template>
@@ -179,12 +194,12 @@ watch(loading, (isLoading) => {
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" />
<BookCoverCard v-for="book in books" :key="book.id" :book="book" @action="handleBookAction(book, $event)" />
</div>
<!-- List view -->
<div v-else class="flex flex-col divide-y divide-border">
<BookListRow v-for="book in books" :key="book.id" :book="book" />
<BookListRow v-for="book in books" :key="book.id" :book="book" @action="handleBookAction(book, $event)" />
</div>
<div ref="sentinel" class="h-8 mt-4 flex items-center justify-center">
@@ -194,4 +209,11 @@ watch(loading, (isLoading) => {
</main>
</SidebarInset>
</SidebarProvider>
<BookQuickView
:book-id="quickViewBookId"
:open="quickViewOpen"
@update:open="quickViewOpen = $event"
@action="quickViewBookId !== null && handleBookAction({ id: quickViewBookId } as BookCard, $event)"
/>
</template>