feat: add resizable persisted sidebar width

This commit is contained in:
neon
2026-03-06 16:41:49 -06:00
parent 1d60aa902c
commit fd21741e08
6 changed files with 352 additions and 174 deletions
+174 -156
View File
@@ -25,6 +25,16 @@ const emit = defineEmits<{
const confirmingDelete = ref(false)
const exportMenuOpen = ref(false)
function onExport(allFormats: boolean) {
emit('export', allFormats)
exportMenuOpen.value = false
}
function onConfirmDelete() {
emit('delete')
confirmingDelete.value = false
}
watch(
() => props.visible,
(v) => {
@@ -45,183 +55,191 @@ watch(
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-4"
>
<TooltipProvider :delay-duration="0">
<div
v-if="visible"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1 px-2.5 py-2 rounded-full bg-card/90 backdrop-blur-xl border border-primary/40 shadow-[0_8px_32px_rgba(0,0,0,0.35)]"
>
<template v-if="!confirmingDelete && !exportMenuOpen">
<!-- Count -->
<span class="px-2.5 py-0.5 text-sm font-semibold tabular-nums whitespace-nowrap rounded-full bg-primary/10 text-primary">{{ count }}</span>
<div
v-if="visible"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1 px-2.5 py-2 rounded-full bg-card/90 backdrop-blur-xl border border-primary/40 shadow-[0_8px_32px_rgba(0,0,0,0.35)]"
>
<TooltipProvider :delay-duration="0">
<template v-if="!confirmingDelete && !exportMenuOpen">
<!-- Count -->
<span class="px-2.5 py-0.5 text-sm font-semibold tabular-nums whitespace-nowrap rounded-full bg-primary/10 text-primary">{{ count }}</span>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
@click="emit('send')"
>
<Mail :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Send via email</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
@click="emit('send')"
>
<Mail :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Send via email</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
@click="exportMenuOpen = true"
>
<Download :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Export as ZIP</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
@click="exportMenuOpen = true"
>
<Download :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Export as ZIP</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
@click="emit('add-to-collection')"
>
<FolderPlus :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Add to collection</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
@click="emit('add-to-collection')"
>
<FolderPlus :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Add to collection</TooltipContent>
</Tooltip>
<Tooltip v-if="inCollection">
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-destructive hover:bg-destructive hover:text-destructive-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
@click="emit('remove-from-collection')"
>
<FolderMinus :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Remove from collection</TooltipContent>
</Tooltip>
<Tooltip v-if="inCollection">
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="
count > 0
? 'text-destructive hover:bg-destructive hover:text-destructive-foreground'
: 'text-muted-foreground/30 cursor-not-allowed'
"
@click="emit('remove-from-collection')"
>
<FolderMinus :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Remove from collection</TooltipContent>
</Tooltip>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-muted' : 'text-muted-foreground/30 cursor-not-allowed'"
@click="emit('refresh-metadata')"
>
<RefreshCw :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Refresh metadata</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-muted' : 'text-muted-foreground/30 cursor-not-allowed'"
@click="emit('refresh-metadata')"
>
<RefreshCw :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Refresh metadata</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-muted' : 'text-muted-foreground/30 cursor-not-allowed'"
@click="emit('re-extract-cover')"
>
<ImageDown :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Re-extract cover from file</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-muted' : 'text-muted-foreground/30 cursor-not-allowed'"
@click="emit('re-extract-cover')"
>
<ImageDown :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Re-extract cover from file</TooltipContent>
</Tooltip>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-destructive hover:bg-destructive hover:text-destructive-foreground' : 'text-muted-foreground/30 cursor-not-allowed'"
@click="confirmingDelete = true"
>
<Trash2 :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Delete selected</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="
count > 0
? 'text-destructive hover:bg-destructive hover:text-destructive-foreground'
: 'text-muted-foreground/30 cursor-not-allowed'
"
@click="confirmingDelete = true"
>
<Trash2 :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Delete selected</TooltipContent>
</Tooltip>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<Tooltip>
<TooltipTrigger as-child>
<button
class="h-9 w-9 flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="emit('exit')"
>
<X :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Exit selection</TooltipContent>
</Tooltip>
</template>
<Tooltip>
<TooltipTrigger as-child>
<button
class="h-9 w-9 flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="emit('exit')"
>
<X :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Exit selection</TooltipContent>
</Tooltip>
</template>
<template v-else-if="exportMenuOpen">
<span class="px-3 text-sm font-semibold text-foreground whitespace-nowrap">Export as ZIP:</span>
<template v-else-if="exportMenuOpen">
<span class="px-3 text-sm font-semibold text-foreground whitespace-nowrap">Export as ZIP:</span>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<button
class="flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground transition-colors"
@click="emit('export', false); exportMenuOpen = false"
>
Primary only
</button>
<button
class="flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground transition-colors"
@click="onExport(false)"
>
Primary only
</button>
<button
class="flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground transition-colors"
@click="emit('export', true); exportMenuOpen = false"
>
All formats
</button>
<button
class="flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground transition-colors"
@click="onExport(true)"
>
All formats
</button>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<button
class="h-8 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="exportMenuOpen = false"
>
Cancel
</button>
</template>
<button
class="h-8 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="exportMenuOpen = false"
>
Cancel
</button>
</template>
<template v-else>
<span class="px-3 text-sm font-semibold text-destructive whitespace-nowrap">Delete {{ count }} book{{ count === 1 ? '' : 's' }}?</span>
<template v-else>
<span class="px-3 text-sm font-semibold text-destructive whitespace-nowrap">Delete {{ count }} book{{ count === 1 ? '' : 's' }}?</span>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<button
class="h-8 px-3 rounded-full text-sm font-medium text-destructive hover:bg-destructive hover:text-destructive-foreground transition-colors"
@click="emit('delete'); confirmingDelete = false"
>
Delete
</button>
<button
class="h-8 px-3 rounded-full text-sm font-medium text-destructive hover:bg-destructive hover:text-destructive-foreground transition-colors"
@click="onConfirmDelete"
>
Delete
</button>
<button
class="h-8 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="confirmingDelete = false"
>
Cancel
</button>
</template>
</div>
</TooltipProvider>
<button
class="h-8 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="confirmingDelete = false"
>
Cancel
</button>
</template>
</TooltipProvider>
</div>
</Transition>
</template>
@@ -4,14 +4,8 @@ import { defaultDocument, useEventListener, useMediaQuery, useVModel } from '@vu
import { TooltipProvider } from 'reka-ui'
import { computed, ref } from 'vue'
import { cn } from '@/lib/utils'
import {
provideSidebarContext,
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_KEYBOARD_SHORTCUT,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON,
} from './utils'
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH_ICON } from './utils'
import { useSidebarWidth } from './useSidebarWidth'
const props = withDefaults(
defineProps<{
@@ -63,6 +57,8 @@ useEventListener('keydown', (event: KeyboardEvent) => {
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = computed(() => (open.value ? 'expanded' : 'collapsed'))
const { widthPx, setWidth, minWidthPx, maxWidthPx } = useSidebarWidth()
const desktopSidebarWidth = computed(() => `${widthPx.value}px`)
provideSidebarContext({
state,
@@ -72,6 +68,10 @@ provideSidebarContext({
openMobile,
setOpenMobile,
toggleSidebar,
widthPx,
setWidth,
minWidthPx,
maxWidthPx,
})
</script>
@@ -80,7 +80,7 @@ provideSidebarContext({
<div
data-slot="sidebar-wrapper"
:style="{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width': desktopSidebarWidth,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
}"
:class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)"
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { onBeforeUnmount, ref } from 'vue'
import { cn } from '@/lib/utils'
import { useSidebar } from './utils'
@@ -7,7 +8,80 @@ const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { toggleSidebar } = useSidebar()
const { toggleSidebar, setWidth, widthPx, state } = useSidebar()
const DRAG_THRESHOLD_PX = 3
const activePointerId = ref<number | null>(null)
const dragStartX = ref(0)
const dragStartWidth = ref(0)
const dragSide = ref<'left' | 'right'>('left')
const isResizeGesture = ref(false)
const didDrag = ref(false)
function resolveSide(target: EventTarget | null): 'left' | 'right' {
const element = target instanceof HTMLElement ? target : null
const side = element?.closest('[data-side]')?.getAttribute('data-side')
return side === 'right' ? 'right' : 'left'
}
function onPointerMove(event: PointerEvent) {
if (activePointerId.value === null || event.pointerId !== activePointerId.value) return
if (!isResizeGesture.value) return
const deltaX = event.clientX - dragStartX.value
if (Math.abs(deltaX) >= DRAG_THRESHOLD_PX) {
didDrag.value = true
}
const nextWidth = dragSide.value === 'left' ? dragStartWidth.value + deltaX : dragStartWidth.value - deltaX
setWidth(nextWidth)
}
function cleanupDragListeners() {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerEnd)
window.removeEventListener('pointercancel', onPointerEnd)
}
function resetDragState() {
activePointerId.value = null
isResizeGesture.value = false
didDrag.value = false
}
function onPointerEnd(event: PointerEvent) {
if (activePointerId.value === null || event.pointerId !== activePointerId.value) return
const shouldToggleSidebar = event.type === 'pointerup' && !didDrag.value
cleanupDragListeners()
resetDragState()
if (shouldToggleSidebar) {
toggleSidebar()
}
}
function onPointerDown(event: PointerEvent) {
if (event.button !== 0) return
activePointerId.value = event.pointerId
dragStartX.value = event.clientX
dragStartWidth.value = widthPx.value
dragSide.value = resolveSide(event.currentTarget)
isResizeGesture.value = state.value === 'expanded'
didDrag.value = false
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerEnd)
window.addEventListener('pointercancel', onPointerEnd)
event.preventDefault()
}
onBeforeUnmount(() => {
cleanupDragListeners()
resetDragState()
})
</script>
<template>
@@ -18,7 +92,7 @@ const { toggleSidebar } = useSidebar()
:tabindex="-1"
:class="
cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:top-0 after:bottom-0 after:left-1/2 after:w-[2px] after:rounded-full group-data-[variant=floating]:after:top-3 group-data-[variant=floating]:after:bottom-3 sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
@@ -27,7 +101,7 @@ const { toggleSidebar } = useSidebar()
props.class,
)
"
@click="toggleSidebar"
@pointerdown="onPointerDown"
>
<slot />
</button>
@@ -0,0 +1,69 @@
import { onBeforeUnmount, ref } from 'vue'
import { storage } from '@/services/storage'
const STORAGE_KEY = 'projectx:sidebar:width'
export const SIDEBAR_WIDTH_DEFAULT_PX = 256
export const SIDEBAR_WIDTH_MIN_PX = 224
export const SIDEBAR_WIDTH_MAX_PX = 480
export const SIDEBAR_WIDTH_PERSIST_DEBOUNCE_MS = 120
function clampSidebarWidth(value: number): number {
if (!Number.isFinite(value)) return SIDEBAR_WIDTH_DEFAULT_PX
const rounded = Math.round(value)
return Math.min(SIDEBAR_WIDTH_MAX_PX, Math.max(SIDEBAR_WIDTH_MIN_PX, rounded))
}
function readInitialSidebarWidth(): number {
const value = storage.get<number>(STORAGE_KEY, SIDEBAR_WIDTH_DEFAULT_PX)
return clampSidebarWidth(value)
}
export function useSidebarWidth() {
const widthPx = ref<number>(readInitialSidebarWidth())
let persistTimer: ReturnType<typeof setTimeout> | null = null
let pendingPersistWidth: number | null = null
function persistWidth(width: number) {
storage.set(STORAGE_KEY, width)
}
function schedulePersist(width: number) {
pendingPersistWidth = width
if (persistTimer) {
clearTimeout(persistTimer)
}
persistTimer = setTimeout(() => {
if (pendingPersistWidth !== null) {
persistWidth(pendingPersistWidth)
pendingPersistWidth = null
}
persistTimer = null
}, SIDEBAR_WIDTH_PERSIST_DEBOUNCE_MS)
}
function setWidth(value: number) {
const clampedWidth = clampSidebarWidth(value)
if (clampedWidth === widthPx.value) return
widthPx.value = clampedWidth
schedulePersist(clampedWidth)
}
onBeforeUnmount(() => {
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
if (pendingPersistWidth !== null) {
persistWidth(pendingPersistWidth)
pendingPersistWidth = null
}
})
return {
widthPx,
setWidth,
minWidthPx: SIDEBAR_WIDTH_MIN_PX,
maxWidthPx: SIDEBAR_WIDTH_MAX_PX,
}
}
@@ -16,4 +16,8 @@ export const [useSidebar, provideSidebarContext] = createContext<{
openMobile: Ref<boolean>
setOpenMobile: (value: boolean) => void
toggleSidebar: () => void
widthPx: Ref<number>
setWidth: (value: number) => void
minWidthPx: number
maxWidthPx: number
}>('Sidebar')
@@ -2,9 +2,9 @@
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, ExternalLink, FolderPlus, Pencil, Star, Trash2, X } from 'lucide-vue-next'
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
import { Sheet, SheetContent, SheetTitle, SheetDescription } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { DialogRoot, DialogContent, DialogPortal, DialogOverlay, DialogClose } from 'reka-ui'
import { DialogRoot, DialogContent, DialogPortal, DialogOverlay, DialogClose, DialogTitle, DialogDescription } from 'reka-ui'
import { formatBytes } from '@/lib/formatting'
import { getProviderColor } from '@/lib/provider-colors'
import { useBookDetail } from '../composables/useBookDetail'
@@ -151,12 +151,20 @@ function editMetadata() {
router.push({ name: 'book-edit', params: { bookId: detail.value.id } })
emit('update:open', false)
}
function openDetails() {
if (!detail.value) return
router.push({ name: 'book-detail', params: { bookId: detail.value.id } })
emit('update:open', false)
}
</script>
<template>
<TooltipProvider :delay-duration="0">
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
<SheetContent side="right" class="sm:max-w-[400px] p-0 overflow-hidden">
<SheetTitle class="sr-only">{{ detail?.title ? `Quick view for ${detail.title}` : 'Book quick view' }}</SheetTitle>
<SheetDescription class="sr-only">Preview book details and run quick actions.</SheetDescription>
<div class="flex flex-col h-full">
<!-- Header: cover + title block -->
<div class="p-5 pt-10 border-b shrink-0">
@@ -190,9 +198,9 @@ function editMetadata() {
<!-- Info -->
<div class="flex-1 min-w-0 pr-2">
<SheetTitle class="text-sm font-bold leading-snug line-clamp-3">
<h2 class="text-sm font-bold leading-snug line-clamp-3">
{{ detail.title ?? 'Untitled' }}
</SheetTitle>
</h2>
<p v-if="detail.subtitle" class="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{{ detail.subtitle }}
</p>
@@ -261,7 +269,10 @@ function editMetadata() {
<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">
<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>
@@ -339,7 +350,7 @@ function editMetadata() {
</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"
@click="router.push({ name: 'book-detail', params: { bookId: detail!.id } }); emit('update:open', false)"
@click="openDetails"
>
<ExternalLink class="size-4" />
Details
@@ -391,6 +402,8 @@ function editMetadata() {
<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 ? `${detail.title} cover preview` : 'Book cover preview' }}</DialogTitle>
<DialogDescription class="sr-only">Enlarged cover image preview dialog.</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"