mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(client): stabilize book detail layout across screen sizes
Keep cover actions and discovery content adjacent to their related sections, and scale the synopsis preview for taller viewports. Fixes #1259
This commit is contained in:
@@ -278,23 +278,32 @@ watch(coverSrc, () => {
|
||||
|
||||
/**
|
||||
* The cover frame follows the library's aspect ratio, so a 1/1 library makes it much shorter than
|
||||
* a 2/3 one. The column height is fixed by the grid, so the width is derived from the height that
|
||||
* is actually available: that keeps the frame's ratio exact instead of letting it stretch.
|
||||
* a 2/3 one. The column height is fixed by the grid, so the width is derived from the space left
|
||||
* after the actions: that keeps the group top-packed without overflowing short viewports.
|
||||
*/
|
||||
const coverSlotEl = ref<HTMLElement | null>(null)
|
||||
const coverSlotHeight = ref(0)
|
||||
let coverSlotObserver: ResizeObserver | null = null
|
||||
const coverColumnEl = ref<HTMLElement | null>(null)
|
||||
const coverActionsEl = ref<HTMLElement | null>(null)
|
||||
const coverColumnHeight = ref(0)
|
||||
const coverActionsHeight = ref(0)
|
||||
let coverLayoutObserver: ResizeObserver | null = null
|
||||
const COVER_GROUP_GAP_PX = 16
|
||||
|
||||
const coverMaxWidth = computed(() => {
|
||||
if (coverSlotHeight.value <= 0) return undefined
|
||||
const availableHeight = coverColumnHeight.value - coverActionsHeight.value - COVER_GROUP_GAP_PX
|
||||
if (availableHeight <= 0) return undefined
|
||||
const parts = detailCoverAspectRatio.value.split('/').map((part) => Number(part.trim()))
|
||||
const width = parts[0]
|
||||
const height = parts[1]
|
||||
if (width === undefined || height === undefined) return undefined
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) return undefined
|
||||
return `${Math.floor(coverSlotHeight.value * (width / height))}px`
|
||||
return `${Math.floor(availableHeight * (width / height))}px`
|
||||
})
|
||||
|
||||
function measureCoverLayout() {
|
||||
coverColumnHeight.value = coverColumnEl.value?.clientHeight ?? 0
|
||||
coverActionsHeight.value = coverActionsEl.value?.getBoundingClientRect().height ?? 0
|
||||
}
|
||||
|
||||
function toggleDescription() {
|
||||
descriptionExpanded.value = !descriptionExpanded.value
|
||||
}
|
||||
@@ -304,20 +313,22 @@ function handleMobileScoreOpen(open: boolean) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
coverSlotObserver = new ResizeObserver((entries) => {
|
||||
coverSlotHeight.value = entries[0]?.contentRect.height ?? 0
|
||||
})
|
||||
if (coverSlotEl.value) coverSlotObserver.observe(coverSlotEl.value)
|
||||
coverLayoutObserver = new ResizeObserver(measureCoverLayout)
|
||||
if (coverColumnEl.value) coverLayoutObserver.observe(coverColumnEl.value)
|
||||
if (coverActionsEl.value) coverLayoutObserver.observe(coverActionsEl.value)
|
||||
measureCoverLayout()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
coverSlotObserver?.disconnect()
|
||||
coverSlotObserver = null
|
||||
coverLayoutObserver?.disconnect()
|
||||
coverLayoutObserver = null
|
||||
})
|
||||
|
||||
watch(coverSlotEl, (element) => {
|
||||
coverSlotObserver?.disconnect()
|
||||
if (element) coverSlotObserver?.observe(element)
|
||||
watch([coverColumnEl, coverActionsEl], ([column, actions]) => {
|
||||
coverLayoutObserver?.disconnect()
|
||||
if (column) coverLayoutObserver?.observe(column)
|
||||
if (actions) coverLayoutObserver?.observe(actions)
|
||||
measureCoverLayout()
|
||||
})
|
||||
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
@@ -1185,22 +1196,21 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Direction A. Below 46rem of pane width the page is a single scrolling column. Above it
|
||||
the pane owns the height: three columns fill row one, the discovery shelf takes row two,
|
||||
and any column that runs long scrolls inside itself so the page itself never does.
|
||||
-->
|
||||
<!-- Below 46rem of pane width the page is a single column. Above it, the shelf follows the
|
||||
natural height of the three-column content instead of being pinned to the viewport bottom. -->
|
||||
<div
|
||||
class="flex flex-col gap-5 @min-[46rem]/book-detail:grid @min-[46rem]/book-detail:h-full @min-[46rem]/book-detail:min-h-0 @min-[46rem]/book-detail:grid-cols-[clamp(12rem,23cqi,17rem)_minmax(16rem,1fr)_clamp(15rem,26cqi,19.25rem)] @min-[46rem]/book-detail:grid-rows-[minmax(0,1fr)_clamp(11.25rem,29%,17.5rem)] @min-[46rem]/book-detail:gap-x-6 @min-[46rem]/book-detail:gap-y-5"
|
||||
data-test="details-layout"
|
||||
class="flex flex-col gap-5 @min-[46rem]/book-detail:grid @min-[46rem]/book-detail:content-start @min-[46rem]/book-detail:grid-cols-[clamp(12rem,23cqi,17rem)_minmax(16rem,1fr)_clamp(15rem,26cqi,19.25rem)] @min-[46rem]/book-detail:gap-x-6 @min-[46rem]/book-detail:gap-y-5"
|
||||
>
|
||||
<!-- Cover column -->
|
||||
<div
|
||||
class="flex min-w-0 flex-col gap-3 @min-[46rem]/book-detail:col-start-1 @min-[46rem]/book-detail:row-start-1 @min-[46rem]/book-detail:min-h-0"
|
||||
ref="coverColumnEl"
|
||||
data-test="cover-column"
|
||||
class="flex min-w-0 flex-col gap-4 @min-[46rem]/book-detail:col-start-1 @min-[46rem]/book-detail:row-start-1 @min-[46rem]/book-detail:min-h-0"
|
||||
>
|
||||
<div class="flex items-start gap-4 sm:gap-5 @min-[46rem]/book-detail:block @min-[46rem]/book-detail:min-h-0 @min-[46rem]/book-detail:flex-1">
|
||||
<div class="flex items-start gap-4 sm:gap-5 @min-[46rem]/book-detail:block @min-[46rem]/book-detail:min-h-0">
|
||||
<div
|
||||
ref="coverSlotEl"
|
||||
class="w-28 shrink-0 sm:w-36 @min-[46rem]/book-detail:flex @min-[46rem]/book-detail:h-full @min-[46rem]/book-detail:w-full @min-[46rem]/book-detail:items-center @min-[46rem]/book-detail:justify-center"
|
||||
class="w-28 shrink-0 sm:w-36 @min-[46rem]/book-detail:flex @min-[46rem]/book-detail:w-full @min-[46rem]/book-detail:items-start @min-[46rem]/book-detail:justify-end"
|
||||
:class="hasCover && coverLoaded && !coverFailed ? 'cursor-zoom-in' : ''"
|
||||
>
|
||||
<div
|
||||
@@ -1307,163 +1317,165 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<!-- Read/Play button: split when multiple files, plain when single -->
|
||||
<div v-if="hasMultipleFiles" class="flex flex-1 h-9 rounded-md overflow-hidden">
|
||||
<div ref="coverActionsEl" data-test="cover-actions">
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<!-- Read/Play button: split when multiple files, plain when single -->
|
||||
<div v-if="hasMultipleFiles" class="flex flex-1 h-9 rounded-md overflow-hidden">
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-2 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile"
|
||||
@click="openBook"
|
||||
>
|
||||
<BookOpen v-if="isPrimaryAudio" class="size-4" />
|
||||
<BookOpen v-else class="size-4" />
|
||||
{{ isPrimaryAudio ? t('book.detail.details.listen') : t('book.detail.details.read') }}
|
||||
</button>
|
||||
<div class="w-px bg-primary-foreground/20 shrink-0" />
|
||||
<Popover :open="readMenuOpen" @update:open="(v) => (readMenuOpen = v)">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
class="w-8 shrink-0 flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
:title="t('book.detail.details.chooseFormat')"
|
||||
>
|
||||
<ChevronDown class="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-52 p-1" align="end">
|
||||
<button
|
||||
v-for="file in openableFiles"
|
||||
:key="file.id"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
||||
@click="openBookFile(file)"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border shrink-0"
|
||||
:style="formatBadgeStyle(file.format ?? '?')"
|
||||
>{{ file.format ?? '?' }}</span
|
||||
>
|
||||
<span class="flex-1 text-left text-muted-foreground text-xs truncate">
|
||||
<template v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">{{
|
||||
t('book.detail.details.audiobook')
|
||||
}}</template>
|
||||
<template v-else>{{ formatFileSize(file.sizeBytes) }}</template>
|
||||
</span>
|
||||
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="text-[10px] text-primary font-medium shrink-0">{{
|
||||
t('book.detail.details.primary')
|
||||
}}</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-2 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
v-else
|
||||
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 v-if="isPrimaryAudio" class="size-4" />
|
||||
<Headphones v-if="isPrimaryAudio" class="size-4" />
|
||||
<BookOpen v-else class="size-4" />
|
||||
{{ isPrimaryAudio ? t('book.detail.details.listen') : t('book.detail.details.read') }}
|
||||
</button>
|
||||
<div class="w-px bg-primary-foreground/20 shrink-0" />
|
||||
<Popover :open="readMenuOpen" @update:open="(v) => (readMenuOpen = v)">
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex items-center justify-center h-9 w-12 shrink-0 rounded-md border border-input bg-background hover:bg-muted transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile"
|
||||
:aria-label="t('book.detail.details.peek')"
|
||||
@click="peekBook"
|
||||
>
|
||||
<Eye class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('book.detail.details.peek') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div v-if="hasPermission('library_download')" class="flex-1">
|
||||
<BookDownloadButton :files="book.files" :book-id="book.id" />
|
||||
</div>
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
|
||||
@click="addToCollectionOpen = true"
|
||||
>
|
||||
<Library class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
v-if="hasPermission('email_send')"
|
||||
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
|
||||
:aria-label="t('book.detail.details.sendViaEmail')"
|
||||
@click="handleSendFromMenu"
|
||||
>
|
||||
<Send class="size-3.5" />
|
||||
</button>
|
||||
<Popover v-if="canEditMetadata || hasPermission('library_delete_books')" :open="moreMenuOpen" @update:open="(v) => (moreMenuOpen = v)">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
class="w-8 shrink-0 flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
:title="t('book.detail.details.chooseFormat')"
|
||||
class="flex flex-1 items-center justify-center h-9 rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<ChevronDown class="size-3.5" />
|
||||
<MoreVertical class="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-52 p-1" align="end">
|
||||
<PopoverContent class="w-44 p-1" align="end">
|
||||
<button
|
||||
v-for="file in openableFiles"
|
||||
:key="file.id"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
||||
@click="openBookFile(file)"
|
||||
v-if="canEditMetadata"
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-foreground hover:bg-muted transition-colors"
|
||||
@click="handleOpenResetReadingState"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border shrink-0"
|
||||
:style="formatBadgeStyle(file.format ?? '?')"
|
||||
>{{ file.format ?? '?' }}</span
|
||||
>
|
||||
<span class="flex-1 text-left text-muted-foreground text-xs truncate">
|
||||
<template v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">{{
|
||||
t('book.detail.details.audiobook')
|
||||
}}</template>
|
||||
<template v-else>{{ formatFileSize(file.sizeBytes) }}</template>
|
||||
</span>
|
||||
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="text-[10px] text-primary font-medium shrink-0">{{
|
||||
t('book.detail.details.primary')
|
||||
}}</span>
|
||||
<RotateCcw class="size-3.5" />
|
||||
Reset reading state
|
||||
</button>
|
||||
<button
|
||||
v-if="hasPermission('library_edit_metadata')"
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
||||
@click="handleMoveFromMenu"
|
||||
>
|
||||
<FolderInput class="size-3.5" />
|
||||
{{ t('book.move.action') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasPermission('library_delete_books')"
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||
@click="handleDeleteFromMenu"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
Delete book
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
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"
|
||||
>
|
||||
<Headphones v-if="isPrimaryAudio" class="size-4" />
|
||||
<BookOpen v-else class="size-4" />
|
||||
{{ isPrimaryAudio ? t('book.detail.details.listen') : t('book.detail.details.read') }}
|
||||
</button>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex items-center justify-center h-9 w-12 shrink-0 rounded-md border border-input bg-background hover:bg-muted transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile"
|
||||
:aria-label="t('book.detail.details.peek')"
|
||||
@click="peekBook"
|
||||
>
|
||||
<Eye class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('book.detail.details.peek') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div v-if="hasPermission('library_download')" class="flex-1">
|
||||
<BookDownloadButton :files="book.files" :book-id="book.id" />
|
||||
<div v-for="held in resetHeldDevices" :key="held.deviceId" class="mt-2 flex items-start gap-1.5">
|
||||
<TriangleAlert class="size-3 text-amber-500 shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] text-amber-500">
|
||||
{{ t('book.detail.details.resetHoldNotice', { device: held.device, percent: formatPercent(held.percentage) }) }}
|
||||
</p>
|
||||
<button
|
||||
class="mt-0.5 text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isReleasingHold(held.deviceId)"
|
||||
@click="handleReleaseResetHold(held.deviceId)"
|
||||
>
|
||||
{{ isReleasingHold(held.deviceId) ? t('book.detail.details.resetHoldReleasing') : t('book.detail.details.resetHoldRelease') }}
|
||||
</button>
|
||||
<p v-if="hasReleaseFailed(held.deviceId)" role="alert" class="text-[11px] text-destructive">
|
||||
{{ t('book.detail.details.resetHoldReleaseFailed') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
|
||||
@click="addToCollectionOpen = true"
|
||||
>
|
||||
<Library class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
v-if="hasPermission('email_send')"
|
||||
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
|
||||
:aria-label="t('book.detail.details.sendViaEmail')"
|
||||
@click="handleSendFromMenu"
|
||||
>
|
||||
<Send class="size-3.5" />
|
||||
</button>
|
||||
<Popover v-if="canEditMetadata || hasPermission('library_delete_books')" :open="moreMenuOpen" @update:open="(v) => (moreMenuOpen = v)">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center h-9 rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<MoreVertical class="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-44 p-1" align="end">
|
||||
<button
|
||||
v-if="canEditMetadata"
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-foreground hover:bg-muted transition-colors"
|
||||
@click="handleOpenResetReadingState"
|
||||
>
|
||||
<RotateCcw class="size-3.5" />
|
||||
Reset reading state
|
||||
</button>
|
||||
<button
|
||||
v-if="hasPermission('library_edit_metadata')"
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
||||
@click="handleMoveFromMenu"
|
||||
>
|
||||
<FolderInput class="size-3.5" />
|
||||
{{ t('book.move.action') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasPermission('library_delete_books')"
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||
@click="handleDeleteFromMenu"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
Delete book
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Tooltip v-if="koboAnomaly">
|
||||
<TooltipTrigger as-child>
|
||||
<div class="mt-2 flex items-center gap-1.5 cursor-help" tabindex="0">
|
||||
<TriangleAlert class="size-3 text-amber-500 shrink-0" />
|
||||
<p class="text-[11px] text-amber-500">{{ koboAnomaly.label }}</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ koboAnomaly.tooltip }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-for="held in resetHeldDevices" :key="held.deviceId" class="mt-2 flex items-start gap-1.5">
|
||||
<TriangleAlert class="size-3 text-amber-500 shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] text-amber-500">
|
||||
{{ t('book.detail.details.resetHoldNotice', { device: held.device, percent: formatPercent(held.percentage) }) }}
|
||||
</p>
|
||||
<button
|
||||
class="mt-0.5 text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isReleasingHold(held.deviceId)"
|
||||
@click="handleReleaseResetHold(held.deviceId)"
|
||||
>
|
||||
{{ isReleasingHold(held.deviceId) ? t('book.detail.details.resetHoldReleasing') : t('book.detail.details.resetHoldRelease') }}
|
||||
</button>
|
||||
<p v-if="hasReleaseFailed(held.deviceId)" role="alert" class="text-[11px] text-destructive">
|
||||
{{ t('book.detail.details.resetHoldReleaseFailed') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip v-if="koboAnomaly">
|
||||
<TooltipTrigger as-child>
|
||||
<div class="mt-2 flex items-center gap-1.5 cursor-help" tabindex="0">
|
||||
<TriangleAlert class="size-3 text-amber-500 shrink-0" />
|
||||
<p class="text-[11px] text-amber-500">{{ koboAnomaly.label }}</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ koboAnomaly.tooltip }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Main column -->
|
||||
@@ -1847,8 +1859,9 @@ watch(
|
||||
<div
|
||||
v-if="book.description"
|
||||
:id="`book-${book.id}-synopsis`"
|
||||
data-test="synopsis-copy"
|
||||
class="mt-2 text-sm leading-relaxed text-foreground"
|
||||
:class="{ 'line-clamp-4': !descriptionExpanded }"
|
||||
:class="{ 'synopsis-copy--clamped': !descriptionExpanded }"
|
||||
v-html="safeDescription"
|
||||
/>
|
||||
<p v-else class="mt-2 text-sm italic text-muted-foreground">{{ t('book.detail.details.noDescription') }}</p>
|
||||
@@ -2123,7 +2136,7 @@ watch(
|
||||
</div>
|
||||
|
||||
<!-- Discovery shelf -->
|
||||
<div class="min-w-0 @min-[46rem]/book-detail:col-span-3 @min-[46rem]/book-detail:row-start-2 @min-[46rem]/book-detail:min-h-0">
|
||||
<div data-test="discovery-shelf" class="min-w-0 @min-[46rem]/book-detail:col-span-3 @min-[46rem]/book-detail:row-start-2">
|
||||
<DiscoverRow class="h-full" :book-id="book.id" :series-name="book.seriesName" :author-count="book.authors.length" size="lg" flush />
|
||||
</div>
|
||||
</div>
|
||||
@@ -2182,3 +2195,24 @@ watch(
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.synopsis-copy--clamped {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
|
||||
@media (min-height: 56rem) {
|
||||
.synopsis-copy--clamped {
|
||||
-webkit-line-clamp: 6;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 80rem) {
|
||||
.synopsis-copy--clamped {
|
||||
-webkit-line-clamp: 10;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -230,14 +230,19 @@ describe('DetailsTab cover surface', () => {
|
||||
expect(surfaces.every((surface) => surface.attributes('style')?.includes('aspect-ratio: 2 / 1'))).toBe(true)
|
||||
})
|
||||
|
||||
it('only constrains cover width from the observed slot height in the wide layout', async () => {
|
||||
it('constrains cover width to the column height remaining above its actions', async () => {
|
||||
const wrapper = mountDetails(makeBook())
|
||||
await flushPromises()
|
||||
|
||||
const surface = wrapper.get('.book-cover-surface')
|
||||
const frame = surface.element.parentElement as HTMLElement
|
||||
const column = wrapper.get<HTMLElement>('[data-test="cover-column"]')
|
||||
const actions = wrapper.get<HTMLElement>('[data-test="cover-actions"]')
|
||||
|
||||
resizeObserverCallbacks.at(-1)?.([{ contentRect: { height: 220 } } as ResizeObserverEntry], {} as ResizeObserver)
|
||||
Object.defineProperty(column.element, 'clientHeight', { configurable: true, value: 320 })
|
||||
vi.spyOn(actions.element, 'getBoundingClientRect').mockReturnValue({ height: 84 } as DOMRect)
|
||||
|
||||
resizeObserverCallbacks.at(-1)?.([], {} as ResizeObserver)
|
||||
await flushPromises()
|
||||
|
||||
expect(frame.style.maxWidth).toBe('')
|
||||
@@ -245,6 +250,55 @@ describe('DetailsTab cover surface', () => {
|
||||
expect(frame.classList).toContain('@min-[46rem]/book-detail:max-w-[var(--detail-cover-max-width)]')
|
||||
})
|
||||
|
||||
it('anchors wide-layout covers beside the top of the metadata column', async () => {
|
||||
const wrapper = mountDetails(makeBook())
|
||||
await flushPromises()
|
||||
|
||||
const surface = wrapper.get('.book-cover-surface')
|
||||
const slot = surface.element.parentElement?.parentElement as HTMLElement
|
||||
|
||||
expect(slot.classList).toContain('@min-[46rem]/book-detail:items-start')
|
||||
expect(slot.classList).toContain('@min-[46rem]/book-detail:justify-end')
|
||||
expect(slot.classList).not.toContain('@min-[46rem]/book-detail:items-center')
|
||||
})
|
||||
|
||||
it('keeps wide-layout actions immediately after the cover instead of at the bottom of the column', async () => {
|
||||
const wrapper = mountDetails(makeBook())
|
||||
await flushPromises()
|
||||
|
||||
const column = wrapper.get<HTMLElement>('[data-test="cover-column"]')
|
||||
const actions = wrapper.get<HTMLElement>('[data-test="cover-actions"]')
|
||||
const coverRow = column.element.firstElementChild as HTMLElement
|
||||
|
||||
expect(actions.element.parentElement).toBe(column.element)
|
||||
expect(coverRow.nextElementSibling).toBe(actions.element)
|
||||
expect(coverRow.classList).not.toContain('@min-[46rem]/book-detail:flex-1')
|
||||
})
|
||||
|
||||
it('lets the discovery shelf follow the natural height of the detail content', async () => {
|
||||
const wrapper = mountDetails(makeBook())
|
||||
await flushPromises()
|
||||
|
||||
const layout = wrapper.get<HTMLElement>('[data-test="details-layout"]')
|
||||
const shelf = wrapper.get<HTMLElement>('[data-test="discovery-shelf"]')
|
||||
|
||||
expect(shelf.element.parentElement).toBe(layout.element)
|
||||
expect(layout.element.classList).toContain('@min-[46rem]/book-detail:content-start')
|
||||
expect(layout.element.classList).not.toContain('@min-[46rem]/book-detail:h-full')
|
||||
expect(layout.element.classList).not.toContain('@min-[46rem]/book-detail:grid-rows-[minmax(0,1fr)_clamp(11.25rem,29%,17.5rem)]')
|
||||
})
|
||||
|
||||
it('uses the responsive synopsis clamp until the full description is opened', async () => {
|
||||
const wrapper = mountDetails(makeBook({ description: '<p>A synopsis</p>' }))
|
||||
await flushPromises()
|
||||
|
||||
const synopsis = wrapper.get<HTMLElement>('[data-test="synopsis-copy"]')
|
||||
|
||||
expect(synopsis.element.classList).toContain('synopsis-copy--clamped')
|
||||
await wrapper.get(`[aria-controls="book-${wrapper.props('book').id}-synopsis"]`).trigger('click')
|
||||
expect(synopsis.element.classList).not.toContain('synopsis-copy--clamped')
|
||||
})
|
||||
|
||||
it('forces spine overlay off for audiobook details covers', async () => {
|
||||
bookSpineOverlay.value = 'strong'
|
||||
|
||||
@@ -335,7 +389,7 @@ describe('DetailsTab cover surface', () => {
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const synopsis = wrapper.get('.line-clamp-4')
|
||||
const synopsis = wrapper.get('[data-test="synopsis-copy"]')
|
||||
expect(synopsis.html()).toContain('Second paragraph')
|
||||
|
||||
const toggle = wrapper.findAll('button').find((button) => button.text() === 'Show more')
|
||||
@@ -345,7 +399,7 @@ describe('DetailsTab cover surface', () => {
|
||||
expect(synopsis.attributes('id')).toBe('book-12-synopsis')
|
||||
await toggle!.trigger('click')
|
||||
|
||||
expect(synopsis.classes()).not.toContain('line-clamp-4')
|
||||
expect(synopsis.classes()).not.toContain('synopsis-copy--clamped')
|
||||
expect(synopsis.classes()).not.toContain('max-h-44')
|
||||
expect(synopsis.classes()).not.toContain('overflow-y-auto')
|
||||
expect(toggle!.text()).toBe('Show less')
|
||||
@@ -353,7 +407,7 @@ describe('DetailsTab cover surface', () => {
|
||||
|
||||
await toggle!.trigger('click')
|
||||
|
||||
expect(synopsis.classes()).toContain('line-clamp-4')
|
||||
expect(synopsis.classes()).toContain('synopsis-copy--clamped')
|
||||
expect(toggle!.text()).toBe('Show more')
|
||||
expect(toggle!.attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user