mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
01b5638ea7
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.
479 lines
21 KiB
Vue
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>
|