Files
bookorbit/client/src/features/book/components/BookQuickView.vue
T
neon 01b5638ea7 feat(cover): show each book's cover in the shape of its primary format
Pick the face slot from the primary file's medium instead of the library's
ratio, and send that frame per book on cards, detail, notebook labels and
recommendations. Grids keep uniform tiles, and the library setting becomes
Grid shape.
2026-10-08 06:19:00 +00:00

479 lines
21 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, ExternalLink, Eye, Folder, FolderPlus, Headphones, Pencil, Star, Trash2, X } from '@lucide/vue'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { Sheet, SheetContent, SheetTitle, SheetDescription } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { DialogRoot, DialogContent, DialogPortal, DialogOverlay, DialogClose, DialogTitle, DialogDescription } from 'reka-ui'
import { formatBytes } from '@/lib/formatting'
import { getProviderColor } from '@/lib/provider-colors'
import { createBookProviderLinks } from '@/features/book/lib/provider-links'
import { useBookDetail } from '../composables/useBookDetail'
import { useCoverVersions } from '../composables/useCoverVersions'
import BookFormatChip from './BookFormatChip.vue'
import { bookFormatEntries, fileFormatKey } from '../lib/book-formats'
import { displayPublishedDate } from '../lib/published-date'
import { FORMAT_TO_GROUP, getPrimaryBookFile } from '@bookorbit/types'
import { useBookCoverAspectRatio } from '../composables/useBookCoverAspectRatio'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
import BookCoverArtwork from './BookCoverArtwork.vue'
import BookCoverSurface from './BookCoverSurface.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{ bookId: number | null; open: boolean }>()
const { hasPermission } = usePermissions()
const emit = defineEmits<{
'update:open': [value: boolean]
action: [type: 'add-to-collection' | 'delete']
}>()
const router = useRouter()
const { detail, loading, fetch } = useBookDetail()
const coverLoaded = ref(false)
const coverFailed = ref(false)
const coverImageRatio = ref<number | null>(null)
const providerIconErrors = ref<Record<string, boolean>>({})
const descriptionExpanded = ref(false)
const genresExpanded = ref(false)
const coverLightboxOpen = ref(false)
watch(
() => props.bookId,
(id) => {
if (id !== null) {
coverLoaded.value = false
coverFailed.value = false
coverImageRatio.value = null
descriptionExpanded.value = false
genresExpanded.value = false
providerIconErrors.value = {}
fetch(id)
}
},
{ immediate: true },
)
const { coverUrl } = useCoverVersions()
const coverSrc = computed(() => (detail.value ? coverUrl(detail.value.id, 'cover', detail.value.coverVersion) : null))
const coverSeed = computed(() => (detail.value ? (detail.value.title ?? detail.value.folderPath.split('/').pop() ?? String(detail.value.id)) : ''))
const coverPlaceholderTitle = computed(() => (detail.value ? (detail.value.title ?? detail.value.folderPath.split('/').pop() ?? null) : null))
const seriesLine = computed(() => {
if (!detail.value?.seriesName) return null
const idx = detail.value.seriesIndex
return idx != null ? `${detail.value.seriesName} #${idx}` : detail.value.seriesName
})
const authorLine = computed(() => detail.value?.authors.map((a) => a.name).join(', ') ?? null)
const ratingStars = [1, 2, 3, 4, 5]
const providerLinks = computed(() => (detail.value ? createBookProviderLinks(detail.value.providerIds) : []))
const safeDescription = useSafeHtml(() => detail.value?.description)
const coverAspectRatio = useBookCoverAspectRatio(() => detail.value)
const { bookCoverDisplayMode } = useDisplaySettings()
const quickViewCoverAspectRatio = computed(() => {
if (
bookCoverDisplayMode.value !== 'natural-bottom' ||
detail.value?.coverSource == null ||
!coverLoaded.value ||
coverFailed.value ||
!coverImageRatio.value
) {
return coverAspectRatio.value
}
return `${coverImageRatio.value} / 1`
})
const primaryFile = computed(() => (detail.value ? getPrimaryBookFile(detail.value.files) : null))
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
const formatEntries = computed(() => (detail.value ? bookFormatEntries(detail.value.files, detail.value.formatPriority) : []))
const publishedDisplay = computed(() => (detail.value ? displayPublishedDate(detail.value.publishedDate, detail.value.publishedYear) : null))
function providerLinkStyle(provider: string) {
const color = getProviderColor(provider)
return {
borderColor: `${color}66`,
backgroundColor: `${color}12`,
}
}
function handleCoverLoad(ratio: number | null) {
coverLoaded.value = true
coverFailed.value = false
coverImageRatio.value = ratio
}
function handleCoverError() {
coverLoaded.value = false
coverFailed.value = true
coverImageRatio.value = null
}
function handleCoverClick() {
if (coverLoaded.value && !coverFailed.value) coverLightboxOpen.value = true
}
function openBookWithMode(mode?: 'peek') {
if (!primaryFile.value || !detail.value) return
router.push({
name: 'reader',
params: { bookId: detail.value.id, fileId: primaryFile.value.id },
query: mode === 'peek' ? { format: primaryFile.value.format ?? 'epub', mode } : { format: primaryFile.value.format ?? 'epub' },
})
emit('update:open', false)
}
function openBook() {
openBookWithMode()
}
function peekBook() {
openBookWithMode('peek')
}
function editMetadata() {
if (!detail.value) return
router.push({ name: 'book-detail', params: { bookId: detail.value.id }, query: { tab: 'edit' } })
emit('update:open', false)
}
function openDetails() {
if (!detail.value) return
router.push({ name: 'book-detail', params: { bookId: detail.value.id } })
emit('update:open', false)
}
function toggleGenres() {
genresExpanded.value = !genresExpanded.value
}
function handleAddToCollection() {
emit('update:open', false)
emit('action', 'add-to-collection')
}
function handleDelete() {
emit('update:open', false)
emit('action', 'delete')
}
</script>
<template>
<TooltipProvider :delay-duration="0">
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
<SheetContent side="right" class="sm:max-w-100 p-0 overflow-hidden">
<SheetTitle class="sr-only">{{
detail?.title ? t('book.quickView.titleFor', { title: detail.title }) : t('book.quickView.title')
}}</SheetTitle>
<SheetDescription class="sr-only">{{ t('book.quickView.description') }}</SheetDescription>
<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="{ aspectRatio: coverAspectRatio }" />
<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 -->
<BookCoverSurface
size="mini"
class="book-cover-surface--spine-fitted w-24 shrink-0 rounded overflow-hidden relative"
:disable-spine="isPrimaryAudio"
:is-comic="isPrimaryComic"
:class="detail.coverSource && !coverFailed ? 'cursor-zoom-in' : ''"
:style="{ aspectRatio: quickViewCoverAspectRatio }"
@click="handleCoverClick"
>
<BookCoverArtwork
:src="coverSrc"
:has-cover="detail.coverSource !== null"
:title="coverPlaceholderTitle"
:author-line="authorLine"
:is-audio="isPrimaryAudio"
:seed="coverSeed"
:alt="detail.title ?? ''"
:frame-aspect-ratio="quickViewCoverAspectRatio"
loading="eager"
:spine="!isPrimaryAudio"
:is-comic="isPrimaryComic"
@load="handleCoverLoad"
@error="handleCoverError"
/>
</BookCoverSurface>
<!-- Info -->
<div class="flex-1 min-w-0 pr-2">
<h2 class="text-sm font-bold leading-snug line-clamp-3">
{{ detail.title ?? t('book.untitled') }}
</h2>
<p v-if="detail.subtitle" class="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{{ detail.subtitle }}
</p>
<div v-if="providerLinks.length" class="mt-2 flex items-center gap-1">
<a
v-for="link in providerLinks"
:key="link.key"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
:title="t('book.quickView.openIn', { provider: link.label })"
class="inline-flex size-6 items-center justify-center rounded border transition-colors hover:bg-muted/60"
:style="providerLinkStyle(link.key)"
>
<img
v-if="link.iconUrl && !providerIconErrors[link.key]"
:src="link.iconUrl"
:alt="link.label"
class="size-3.5 rounded-[2px] object-contain"
loading="lazy"
@error="providerIconErrors[link.key] = true"
/>
<span v-else class="text-[8px] font-bold leading-none text-foreground">{{ link.fallback }}</span>
</a>
</div>
<p v-if="authorLine" class="text-xs text-foreground mt-2">{{ authorLine }}</p>
<p v-if="seriesLine" class="text-xs text-muted-foreground mt-0.5 italic">{{ seriesLine }}</p>
<div v-if="detail.rating != null" class="mt-2 flex items-center gap-1">
<Star
v-for="star in ratingStars"
:key="star"
class="size-3"
:class="detail.rating >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground'"
/>
<span class="text-[10px] text-muted-foreground ml-1">{{ detail.rating }}/5</span>
</div>
</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">
<!-- Format badges + meta chips -->
<div class="flex flex-wrap gap-1.5">
<BookFormatChip
v-for="entry in formatEntries"
:key="entry.key"
:format-key="entry.key"
:primary="entry.primary"
class="rounded px-2 py-0.5 text-[10px] tracking-wider"
/>
<span v-if="detail.pageCount" class="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted text-muted-foreground">
{{ t('book.quickView.pages', { count: detail.pageCount }) }}
</span>
<span v-if="publishedDisplay" class="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted text-muted-foreground">
{{ publishedDisplay }}
</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>
<!-- Primary file summary -->
<div v-if="primaryFile" class="rounded-md border border-border bg-muted/20 px-3 py-2.5">
<p class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">{{ t('book.quickView.primaryFile') }}</p>
<p class="text-xs text-foreground mt-1 truncate">
{{ primaryFile.filename ?? t('book.quickView.fileNumber', { id: primaryFile.id }) }}
</p>
<div class="mt-1.5 flex items-center gap-1.5">
<BookFormatChip :format-key="fileFormatKey(primaryFile) ?? '?'" class="rounded px-2 py-0.5 text-[10px] tracking-wider" />
<span class="text-[10px] px-2 py-0.5 rounded bg-muted text-muted-foreground">
{{ formatBytes(primaryFile.sizeBytes) }}
</span>
</div>
</div>
<!-- ISBN -->
<dl v-if="detail.isbn13 || detail.isbn10" class="grid grid-cols-1 gap-y-2 border-t pt-4">
<div v-if="detail.isbn13" class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">ISBN-13</dt>
<dd class="text-xs text-foreground mt-0.5 font-mono">{{ detail.isbn13 }}</dd>
</div>
<div v-if="detail.isbn10" class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">ISBN-10</dt>
<dd class="text-xs text-foreground mt-0.5 font-mono">{{ detail.isbn10 }}</dd>
</div>
</dl>
<!-- Genres -->
<div v-if="detail.genres.length">
<div class="flex flex-wrap gap-1.5 overflow-hidden transition-all" :style="genresExpanded ? {} : { maxHeight: '78px' }">
<span v-for="genre in detail.genres" :key="genre" class="text-[11px] px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{{ genre }}
</span>
</div>
<button
v-if="detail.genres.length > 5"
class="text-xs text-muted-foreground hover:text-foreground mt-1.5 transition-colors"
@click="toggleGenres"
>
{{ genresExpanded ? t('book.quickView.showLess') : t('book.quickView.showMore') }}
</button>
</div>
<!-- Collections -->
<div v-if="detail.collections.length" class="border-t pt-4">
<p class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground mb-2">{{ t('book.quickView.collections') }}</p>
<div class="flex flex-wrap gap-1.5">
<span
v-for="col in detail.collections"
:key="col.id"
class="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground"
>
<Folder class="size-2.5 shrink-0" />
{{ col.name }}
</span>
</div>
</div>
<!-- Description -->
<div class="border-t pt-4">
<div v-if="detail.description">
<div
class="text-sm leading-relaxed text-foreground transition-all"
:class="descriptionExpanded ? '' : 'line-clamp-4'"
v-html="safeDescription"
/>
<button
class="text-xs text-muted-foreground hover:text-foreground mt-1.5 transition-colors"
@click="descriptionExpanded = !descriptionExpanded"
>
{{ descriptionExpanded ? t('book.quickView.showLess') : t('book.quickView.showMore') }}
</button>
</div>
<p v-else class="text-xs text-muted-foreground italic">{{ t('book.quickView.noDescription') }}</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"
:aria-label="isPrimaryAudio ? t('book.actions.listen') : t('book.actions.read')"
@click="openBook"
>
<Headphones v-if="isPrimaryAudio" class="size-4" />
<BookOpen v-else class="size-4" />
<span class="hidden sm:inline">{{ isPrimaryAudio ? t('book.actions.listen') : t('book.actions.read') }}</span>
</button>
<button
class="flex flex-1 items-center justify-center text-primary-foreground gap-2 h-9 rounded-md bg-sky-600 text-sm font-medium hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-sky-600 transition-colors"
:aria-label="t('book.actions.details')"
@click="openDetails"
>
<ExternalLink class="size-4" />
<span class="hidden sm:inline">{{ t('book.actions.details') }}</span>
</button>
<Tooltip>
<TooltipTrigger as-child>
<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 disabled:opacity-50"
:disabled="!primaryFile"
:aria-label="t('book.actions.peek')"
@click="peekBook"
>
<Eye class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('book.actions.peek') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="hasPermission('library_edit_metadata')">
<TooltipTrigger as-child>
<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"
@click="editMetadata"
>
<Pencil class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('book.quickView.openMetadataEditor') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
data-testid="quick-view-action-add-to-collection"
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"
@click="handleAddToCollection"
>
<FolderPlus class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('book.actions.addToCollection') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="hasPermission('library_delete_books')">
<TooltipTrigger as-child>
<button
data-testid="quick-view-action-delete"
class="flex items-center justify-center h-9 px-3 rounded-md text-sm text-destructive hover:bg-destructive/10 transition-colors"
@click="handleDelete"
>
<Trash2 class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('common.delete') }}</TooltipContent>
</Tooltip>
</div>
</div>
</SheetContent>
</Sheet>
<!-- Cover lightbox -->
<DialogRoot :open="coverLightboxOpen" @update:open="coverLightboxOpen = $event">
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 bg-scrim-media 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"
>
<DialogTitle class="sr-only">{{
detail?.title ? t('book.quickView.coverPreviewFor', { title: detail.title }) : t('book.quickView.coverPreview')
}}</DialogTitle>
<DialogDescription class="sr-only">{{ t('book.quickView.coverPreviewDescription') }}</DialogDescription>
<img v-if="detail" :src="coverSrc ?? ''" :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>
</TooltipProvider>
</template>