feat: overhaul book detail tab layout and metadata

This commit is contained in:
neon
2026-03-16 01:56:15 -06:00
parent 08fbad434b
commit f81fef7b9b
12 changed files with 259 additions and 242 deletions
+6 -6
View File
@@ -131,7 +131,7 @@ onUnmounted(() => stopUploadCompletedListener())
<LibraryCreatorModal v-if="createLibraryOpen" @close="createLibraryOpen = false" @saved="onLibrarySaved" />
<Sidebar variant="floating" collapsible="icon" style="--sidebar-border: color-mix(in oklch, var(--primary) 18%, transparent)">
<SidebarHeader class="border-b border-sidebar-border/80 bg-gradient-to-b from-primary/10 via-primary/4 to-transparent rounded-t-xl">
<SidebarHeader class="border-b border-sidebar-border/80 bg-linear-to-b from-primary/10 via-primary/4 to-transparent rounded-t-xl">
<div
class="flex items-center gap-2.5 px-1.5 py-1.5 group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:justify-center"
>
@@ -164,9 +164,9 @@ onUnmounted(() => stopUploadCompletedListener())
<SidebarContent>
<!-- Dashboard / Staging / Authors -->
<SidebarGroup class="py-2">
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenu class="gap-px">
<SidebarNavItem
:is-active="isDashboardActive"
tooltip="Dashboard"
@@ -205,7 +205,7 @@ onUnmounted(() => stopUploadCompletedListener())
<SidebarSeparator class="group-data-[collapsible=icon]:hidden" />
<!-- Libraries -->
<SidebarGroup class="py-2">
<SidebarGroup class="mt-1">
<SidebarSectionHeader
label="Libraries"
:is-open="librariesOpen"
@@ -269,7 +269,7 @@ onUnmounted(() => stopUploadCompletedListener())
<SidebarSeparator class="group-data-[collapsible=icon]:hidden" />
<!-- Lenses -->
<SidebarGroup class="py-2">
<SidebarGroup class="mt-1">
<SidebarSectionHeader
label="Lenses"
:is-open="lensesOpen"
@@ -311,7 +311,7 @@ onUnmounted(() => stopUploadCompletedListener())
<SidebarSeparator class="group-data-[collapsible=icon]:hidden" />
<!-- Collections -->
<SidebarGroup class="py-2">
<SidebarGroup class="mt-1">
<SidebarSectionHeader
label="Collections"
:is-open="collectionsOpen"
@@ -22,10 +22,10 @@ const emit = defineEmits<{ click: [] }>()
<SidebarMenuButton
:is-active="isActive"
:tooltip="tooltip"
class="relative h-9 gap-2.5 rounded-xl px-2 transition-[background-color,box-shadow,transform] duration-200 before:absolute before:-left-1.5 before:top-1/2 before:h-5 before:w-[3px] before:-translate-y-1/2 before:scale-y-75 before:rounded-full before:bg-primary before:opacity-0 before:transition-all before:duration-200 hover:translate-x-0.5 hover:bg-primary/8 data-[active=true]:translate-x-0 data-[active=true]:bg-primary/15 data-[active=true]:shadow-[inset_0_0_0_1px_var(--sidebar-border)] data-[active=true]:before:scale-y-100 data-[active=true]:before:opacity-100 group/item group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-1.5"
class="relative h-8.5 gap-2 rounded-xl px-2 transition-[background-color,box-shadow] duration-200 before:absolute before:-left-1 before:top-1/2 before:h-4 before:w-[2.5px] before:-translate-y-1/2 before:scale-y-75 before:rounded-full before:bg-primary before:opacity-0 before:transition-all before:duration-200 hover:bg-primary/8 data-[active=true]:bg-primary/12 data-[active=true]:shadow-[inset_0_0_0_1px_var(--sidebar-border)] data-[active=true]:before:scale-y-100 data-[active=true]:before:opacity-100 group/item group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-1.5"
@click="emit('click')"
>
<span class="inline-flex h-[1.65rem] w-[1.65rem] shrink-0 items-center justify-center rounded-lg transition-colors">
<span class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors">
<component
:is="icon"
:size="iconSize"
@@ -13,30 +13,33 @@ const emit = defineEmits<{ toggle: []; add: [] }>()
</script>
<template>
<div class="flex h-8 items-center px-2 group-data-[collapsible=icon]:hidden">
<div class="flex h-8.5 items-center px-2 group-data-[collapsible=icon]:hidden">
<button class="group/hdr flex min-w-0 flex-1 items-center gap-1.5" @click="emit('toggle')">
<span
class="text-[10px] font-medium uppercase tracking-[0.18em] text-sidebar-foreground/45 transition-colors group-hover/hdr:text-sidebar-foreground/80"
class="text-[11px] font-medium uppercase tracking-[0.16em] text-sidebar-foreground/45 transition-colors group-hover/hdr:text-sidebar-foreground/80"
>
{{ label }}
</span>
<span v-if="!isOpen && collapsedCount && collapsedCount > 0" class="text-[10px] font-medium text-sidebar-foreground/30">
<span
v-if="!isOpen && collapsedCount && collapsedCount > 0"
class="rounded-full bg-sidebar-foreground/10 px-1.5 py-0.5 text-[9px] font-bold text-sidebar-foreground/50 transition-colors group-hover/hdr:bg-sidebar-foreground/15 group-hover/hdr:text-sidebar-foreground/70"
>
{{ collapsedCount }}
</span>
<ChevronDown
:size="12"
:stroke-width="3"
class="ml-auto shrink-0 text-sidebar-foreground/30 transition-transform duration-200 group-hover/hdr:text-sidebar-foreground/60"
:size="13"
:stroke-width="2.5"
class="ml-auto shrink-0 text-sidebar-foreground/35 transition-transform duration-200 group-hover/hdr:text-sidebar-foreground/70"
:class="isOpen ? 'rotate-0' : '-rotate-90'"
/>
</button>
<button
v-if="canAdd"
class="ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
class="ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-sidebar-foreground/45 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/90"
:title="addTitle"
@click="emit('add')"
>
<Plus :size="14" :stroke-width="2.5" />
<Plus :size="15" :stroke-width="2.2" />
</button>
</div>
</template>
@@ -11,7 +11,7 @@ const props = defineProps<{
<div
data-slot="sidebar-content"
data-sidebar="content"
:class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)"
:class="cn('flex min-h-0 flex-1 flex-col overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)"
>
<slot />
</div>
@@ -48,6 +48,8 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
return {
id: 1,
libraryId: 1,
libraryName: 'Test Library',
addedAt: '2024-01-01T00:00:00.000Z',
status: 'present',
title: 'Test Book',
subtitle: null,
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, Download, FolderPlus, Pencil, Star, Trash2, TriangleAlert, X } from 'lucide-vue-next'
import { BookOpen, Download, FolderPlus, MoreHorizontal, Pencil, Star, Trash2, TriangleAlert, X } from 'lucide-vue-next'
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { getFormatColor } from '@/features/book/lib/format-colors'
@@ -27,16 +27,6 @@ type FileProgress = {
updatedAt: string | null
}
type BookmarkItem = {
id: number
title: string
createdAt: string
}
type AnnotationItem = {
id: number
}
type CollectionMembership = {
id: number
name: string
@@ -57,6 +47,7 @@ const router = useRouter()
const addToCollectionOpen = ref(false)
const scoreBreakdownOpen = ref(false)
const moreMenuOpen = ref(false)
const { weights: scoreWeights, fetchWeights } = useMetadataScoreWeights()
@@ -120,8 +111,6 @@ async function setRating(star: number) {
const ratingStars = [1, 2, 3, 4, 5]
const fileProgressById = ref<Record<number, FileProgress>>({})
const bookmarks = ref<BookmarkItem[]>([])
const annotations = ref<AnnotationItem[]>([])
const collections = ref<CollectionMembership[]>([])
const koboState = ref<BookKoboState | null>(null)
const supplementalLoading = ref(false)
@@ -188,11 +177,6 @@ const providerLinks = computed<ProviderLink[]>(() => {
return out
})
const latestBookmark = computed(() => {
if (bookmarks.value.length === 0) return null
return [...bookmarks.value].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]
})
const fileProgressRows = computed(() =>
props.book.files.map((file) => ({
file,
@@ -205,30 +189,57 @@ const fileProgressRows = computed(() =>
})),
)
const detailProgressRows = computed(() => fileProgressRows.value.filter(({ progress }) => progress.percentage > 0))
const activityExpanded = ref(false)
const hasReadingSignals = computed(() => detailProgressRows.value.length > 0 || bookmarks.value.length > 0 || annotations.value.length > 0)
const hasKoboSignals = computed(() => {
if (!canViewKobo.value || !koboState.value) return false
const state = koboState.value
const hasSnapshotFlags =
state.snapshot != null &&
(state.snapshot.inSnapshot ||
state.snapshot.synced != null ||
state.snapshot.pendingDelete != null ||
state.snapshot.isNew != null ||
state.snapshot.removedByDevice != null)
return state.syncCollections.length > 0 || state.readingState != null || hasSnapshotFlags
type ProgressRow = {
label: string
percentage: number
color: string
badgeStyle: Record<string, string>
tooltipText: string
finished: boolean
}
const KOBO_COLOR = '#f59e0b'
const leftColumnProgressRows = computed<ProgressRow[]>(() => {
const rows: ProgressRow[] = []
for (const { file, progress } of detailProgressRows.value) {
const color = getFormatColor(file.format ?? '?')
rows.push({
label: (file.format ?? '?').toUpperCase(),
percentage: progress.percentage,
color,
badgeStyle: { color, borderColor: `${color}66`, backgroundColor: `${color}1a` },
tooltipText: file.absolutePath,
finished: progress.percentage >= 100,
})
}
const koboPercent = koboState.value?.readingState?.progressPercent
if (canViewKobo.value && koboPercent != null && koboPercent > 0) {
const syncCols = koboState.value?.syncCollections ?? []
rows.push({
label: 'Kobo',
percentage: koboPercent,
color: KOBO_COLOR,
badgeStyle: { color: KOBO_COLOR, borderColor: `${KOBO_COLOR}66`, backgroundColor: `${KOBO_COLOR}1a` },
tooltipText: syncCols.length > 0 ? `Via: ${syncCols.join(', ')}` : 'Kobo device',
finished: koboPercent >= 100,
})
}
return rows
})
const showActivitySummary = computed(() => hasReadingSignals.value || hasKoboSignals.value)
const activitySummaryLine = computed(() => {
const parts: string[] = []
const maxProgress = detailProgressRows.value.length > 0 ? Math.max(...detailProgressRows.value.map((r) => r.progress.percentage)) : 0
if (maxProgress > 0) parts.push(`Progress ${formatPercent(maxProgress)}`)
if (bookmarks.value.length > 0) parts.push(`Bookmarks ${bookmarks.value.length}`)
if (annotations.value.length > 0) parts.push(`Highlights ${annotations.value.length}`)
const koboProgress = koboState.value?.readingState?.progressPercent
if (koboProgress != null) parts.push(`Kobo ${formatPercent(koboProgress)}`)
return parts.length > 0 ? parts.join(' · ') : 'No reading activity yet'
const leftColumnProgressVisible = computed(() => leftColumnProgressRows.value.slice(0, 3))
const leftColumnProgressOverflow = computed(() => Math.max(0, leftColumnProgressRows.value.length - 3))
const koboAnomaly = computed(() => {
if (!canViewKobo.value) return null
const snap = koboState.value?.snapshot
if (!snap) return null
if (snap.pendingDelete) return 'Pending delete from device'
if (snap.removedByDevice) return 'Removed by device'
if (snap.synced === false) return 'Not synced'
return null
})
const seriesLine = computed(() => {
@@ -241,25 +252,18 @@ function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const s = Math.floor(diff / 1000)
if (s < 60) return `${s}s ago`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
return `${d}d ago`
}
function formatPercent(value: number): string {
return `${Math.max(0, Math.min(100, Math.round(value)))}%`
}
function boolState(value: boolean | null | undefined): string {
if (value == null) return 'n/a'
return value ? 'yes' : 'no'
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function formatFileSize(bytes: number | null | undefined): string {
if (!bytes) return '-'
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatBadgeStyle(fmt: string) {
@@ -284,6 +288,11 @@ function handleEditMetadataFromScore() {
router.push({ name: 'book-edit', params: { bookId: props.book.id } })
}
function handleDeleteFromMenu() {
moreMenuOpen.value = false
promptDelete(props.book.id)
}
function openBook() {
if (!primaryFile.value) return
router.push({
@@ -325,18 +334,10 @@ async function loadSupplemental() {
}
})
const bookmarksPromise = api(`/api/v1/books/${props.book.id}/bookmarks`)
const annotationsPromise = api(`/api/v1/books/${props.book.id}/annotations`)
const collectionsPromise = api(`/api/v1/collections?bookIds=${props.book.id}`)
const koboPromise = canViewKobo.value ? api(`/api/v1/books/${props.book.id}/kobo-state`) : Promise.resolve(null)
const [progressRows, bookmarksRes, annotationsRes, collectionsRes, koboRes] = await Promise.all([
Promise.all(progressPromises),
bookmarksPromise,
annotationsPromise,
collectionsPromise,
koboPromise,
])
const [progressRows, collectionsRes, koboRes] = await Promise.all([Promise.all(progressPromises), collectionsPromise, koboPromise])
if (requestId !== supplementalRequestId) return
@@ -351,8 +352,6 @@ async function loadSupplemental() {
}
fileProgressById.value = progressMap
bookmarks.value = bookmarksRes.ok ? ((await bookmarksRes.json()) as BookmarkItem[]) : []
annotations.value = annotationsRes.ok ? ((await annotationsRes.json()) as AnnotationItem[]) : []
collections.value = collectionsRes.ok ? ((await collectionsRes.json()) as CollectionMembership[]) : []
if (canViewKobo.value) {
@@ -377,8 +376,6 @@ async function loadSupplemental() {
} catch {
if (requestId !== supplementalRequestId) return
fileProgressById.value = {}
bookmarks.value = []
annotations.value = []
collections.value = []
koboState.value = canViewKobo.value
? {
@@ -396,7 +393,6 @@ async function loadSupplemental() {
watch(
() => `${props.book.id}:${props.book.files.map((f) => f.id).join(',')}:${canViewKobo.value ? 'kobo' : 'nokobo'}`,
() => {
activityExpanded.value = false
providerIconErrors.value = {}
void loadSupplemental()
},
@@ -480,17 +476,67 @@ watch(
</TooltipTrigger>
<TooltipContent>Add to collection</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Popover :open="moreMenuOpen" @update:open="(v) => (moreMenuOpen = v)">
<PopoverTrigger as-child>
<button
class="flex flex-1 items-center justify-center h-9 rounded-md text-sm text-destructive hover:bg-destructive/10 transition-colors"
@click="promptDelete(book.id)"
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
>
<MoreHorizontal class="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent class="w-36 p-1" align="end">
<button
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
</button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</PopoverContent>
</Popover>
</div>
</div>
<div v-if="leftColumnProgressVisible.length" class="mt-4 space-y-2">
<Tooltip v-for="row in leftColumnProgressVisible" :key="row.label">
<TooltipTrigger as-child>
<div class="flex items-center gap-2 cursor-default">
<span
class="w-11 shrink-0 text-center text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border"
:style="row.badgeStyle"
>{{ row.label }}</span
>
<div class="flex-1 h-1 rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full"
:style="{
width: `${Math.min(100, row.percentage)}%`,
backgroundColor: row.finished ? 'rgb(34 197 94 / 0.8)' : row.color,
opacity: row.finished ? '1' : '0.75',
}"
/>
</div>
<span v-if="row.finished" class="text-[11px] font-medium text-green-500 shrink-0">Finished</span>
<span v-else class="text-[11px] text-muted-foreground shrink-0 w-7 text-right">{{ formatPercent(row.percentage) }}</span>
</div>
</TooltipTrigger>
<TooltipContent>{{ row.tooltipText }}</TooltipContent>
</Tooltip>
<p v-if="leftColumnProgressOverflow > 0" class="text-[11px] text-muted-foreground">+{{ leftColumnProgressOverflow }} more</p>
</div>
<div v-if="koboAnomaly" class="mt-2 flex items-center gap-1.5">
<TriangleAlert class="size-3 text-amber-500 shrink-0" />
<p class="text-[11px] text-amber-500">{{ koboAnomaly }}</p>
</div>
<div v-if="collections.length" class="mt-3">
<span class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground block mb-1.5">Collections</span>
<div class="flex flex-wrap gap-1">
<span
v-for="col in collections"
:key="col.id"
class="text-[11px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground border border-border"
>
{{ col.name }}
</span>
</div>
</div>
</div>
@@ -510,7 +556,53 @@ watch(
<MetadataScoreBreakdown :book="book" :weights="scoreWeights" @edit-metadata="handleEditMetadataFromScore" />
</PopoverContent>
</Popover>
<div v-if="providerLinks.length" class="flex items-center gap-1.5 shrink-0">
</div>
<p v-if="book.subtitle" class="text-base text-muted-foreground mt-1 leading-snug">{{ book.subtitle }}</p>
<div class="flex items-baseline flex-wrap gap-x-2 gap-y-1 mt-3">
<p v-if="authorLine" class="text-sm">
<span class="text-muted-foreground">by</span>
<span class="ml-1 font-medium text-foreground">{{ authorLine }}</span>
</p>
<template v-if="seriesLine">
<span class="text-muted-foreground/40 text-xs">·</span>
<span class="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">{{ seriesLine }}</span>
</template>
</div>
<div class="mt-2 flex items-center gap-1" @mouseleave="hoverRating = null">
<template v-if="canEditMetadata">
<Tooltip v-for="star in ratingStars" :key="star">
<TooltipTrigger as-child>
<button type="button" class="p-0.5 transition-colors" @mouseenter="hoverRating = star" @click="setRating(star)">
<Star class="size-3.5" :class="(displayRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/30'" />
</button>
</TooltipTrigger>
<TooltipContent>Rate {{ star }}</TooltipContent>
</Tooltip>
</template>
<template v-else>
<Star
v-for="star in ratingStars"
:key="star"
class="size-3.5"
:class="(localRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/30'"
/>
</template>
</div>
<!-- Format badges + provider links -->
<div v-if="formats.length || providerLinks.length" class="flex items-center flex-wrap gap-2 mt-4">
<span
v-for="fmt in formats"
:key="fmt"
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded border"
:style="formatBadgeStyle(fmt)"
>
{{ fmt }}
</span>
<div v-if="providerLinks.length" class="flex items-center gap-2 shrink-0">
<div class="w-px h-3.5 bg-border" />
<a
v-for="link in providerLinks"
:key="link.key"
@@ -533,169 +625,77 @@ watch(
</a>
</div>
</div>
<p v-if="book.subtitle" class="text-base text-muted-foreground mt-1 leading-snug">{{ book.subtitle }}</p>
<div class="flex items-baseline flex-wrap gap-x-2 gap-y-1 mt-3">
<p v-if="authorLine" class="text-sm">
<span class="text-muted-foreground">by</span>
<span class="ml-1 font-medium text-foreground">{{ authorLine }}</span>
</p>
<template v-if="seriesLine">
<span class="text-muted-foreground/40 text-xs">·</span>
<span class="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">{{ seriesLine }}</span>
</template>
</div>
<!-- Format badges -->
<div v-if="formats.length" class="flex flex-wrap gap-1.5 mt-4">
<span
v-for="fmt in formats"
:key="fmt"
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded border"
:style="formatBadgeStyle(fmt)"
>
{{ fmt }}
</span>
</div>
<!-- Genres -->
<div v-if="book.genres.length" class="flex flex-wrap gap-1.5 mt-4">
<!-- Genres + Tags -->
<div v-if="book.genres.length || book.tags.length" class="flex flex-wrap gap-1.5 mt-4">
<span v-for="genre in book.genres" :key="genre" class="text-xs px-2.5 py-0.5 rounded-full border border-primary/30 text-primary/80">
{{ genre }}
</span>
</div>
<!-- Tags -->
<div v-if="book.tags.length" class="flex flex-wrap gap-1.5 mt-3">
<span v-for="tag in book.tags" :key="tag" class="text-xs px-2.5 py-0.5 rounded-full border border-border bg-muted/40 text-muted-foreground">
<span
v-for="tag in book.tags"
:key="tag"
class="text-xs px-2.5 py-0.5 rounded-full border border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400"
>
#{{ tag }}
</span>
</div>
<!-- Metadata grid -->
<dl class="mt-5 pt-5 border-t border-border grid grid-cols-2 xl:grid-cols-4 gap-x-8 gap-y-4">
<div v-if="book.publisher" class="min-w-0">
<div class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Publisher</dt>
<dd class="text-sm text-foreground mt-0.5 leading-snug">{{ book.publisher }}</dd>
<template v-if="book.publisher">
<Tooltip>
<TooltipTrigger as-child>
<dd class="text-sm text-foreground mt-0.5 truncate cursor-default">{{ book.publisher }}</dd>
</TooltipTrigger>
<TooltipContent>{{ book.publisher }}</TooltipContent>
</Tooltip>
</template>
<dd v-else class="text-sm text-foreground mt-0.5">-</dd>
</div>
<div v-if="book.publishedYear">
<div>
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Published</dt>
<dd class="text-sm text-foreground mt-0.5">{{ book.publishedYear }}</dd>
<dd class="text-sm text-foreground mt-0.5">{{ book.publishedYear || '-' }}</dd>
</div>
<div v-if="book.language">
<div>
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Language</dt>
<dd class="text-sm text-foreground mt-0.5 capitalize">{{ book.language }}</dd>
<dd class="text-sm text-foreground mt-0.5 capitalize">{{ book.language || '-' }}</dd>
</div>
<div v-if="book.pageCount">
<div>
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Pages</dt>
<dd class="text-sm text-foreground mt-0.5">{{ book.pageCount }}</dd>
</div>
<div v-if="book.isbn13" class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">ISBN-13</dt>
<dd class="text-sm text-foreground mt-0.5 font-mono">{{ book.isbn13 }}</dd>
</div>
<div v-if="book.isbn10" class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">ISBN-10</dt>
<dd class="text-sm text-foreground mt-0.5 font-mono">{{ book.isbn10 }}</dd>
<dd class="text-sm text-foreground mt-0.5">{{ book.pageCount || '-' }}</dd>
</div>
<div class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Rating</dt>
<dd class="mt-0.5 flex items-center gap-1" @mouseleave="hoverRating = null">
<template v-if="canEditMetadata">
<Tooltip v-for="star in ratingStars" :key="star">
<TooltipTrigger as-child>
<button type="button" class="p-0.5 transition-colors" @mouseenter="hoverRating = star" @click="setRating(star)">
<Star class="size-3.5" :class="(displayRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/30'" />
</button>
</TooltipTrigger>
<TooltipContent>Rate {{ star }}</TooltipContent>
</Tooltip>
</template>
<template v-else>
<Star
v-for="star in ratingStars"
:key="star"
class="size-3.5"
:class="(localRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/30'"
/>
</template>
<span class="text-xs text-muted-foreground ml-1">{{ localRating ?? 0 }}/5</span>
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">ISBN</dt>
<dd v-if="book.isbn13 || book.isbn10" class="text-sm text-foreground mt-0.5 font-mono space-y-0.5">
<div v-if="book.isbn13">{{ book.isbn13 }}</div>
<div v-if="book.isbn10" :class="book.isbn13 ? 'text-xs text-muted-foreground' : ''">{{ book.isbn10 }}</div>
</dd>
<dd v-else class="text-sm text-foreground mt-0.5">-</dd>
</div>
<div v-if="book.lastWrittenAt" class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Last File Sync</dt>
<dd class="text-sm text-foreground mt-0.5">
{{ formatDateTime(book.lastWrittenAt) }}
<span class="text-xs text-muted-foreground ml-1">({{ formatRelative(book.lastWrittenAt) }})</span>
</dd>
<div>
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">File Size</dt>
<dd class="text-sm text-foreground mt-0.5">{{ formatFileSize(primaryFile?.sizeBytes) }}</dd>
</div>
<div class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Library</dt>
<dd class="text-sm text-foreground mt-0.5">{{ book.libraryName || '-' }}</dd>
</div>
<div class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Added</dt>
<template v-if="book.addedAt">
<Tooltip>
<TooltipTrigger as-child>
<dd class="text-sm text-foreground mt-0.5 truncate cursor-default">{{ formatDate(book.addedAt) }}</dd>
</TooltipTrigger>
<TooltipContent>{{ formatDateTime(book.addedAt) }}</TooltipContent>
</Tooltip>
</template>
<dd v-else class="text-sm text-foreground mt-0.5">-</dd>
</div>
</dl>
<!-- Compact reading + sync summary -->
<div v-if="showActivitySummary" class="mt-5 pt-5 border-t border-border">
<div class="rounded-md border border-border bg-muted/20 px-3 py-2.5">
<div class="flex items-center justify-between gap-3">
<p class="text-xs text-foreground truncate">{{ activitySummaryLine }}</p>
<button
class="text-[11px] text-muted-foreground hover:text-foreground transition-colors shrink-0"
@click="activityExpanded = !activityExpanded"
>
{{ activityExpanded ? 'Hide details' : 'Show details' }}
</button>
</div>
<p v-if="supplementalLoading" class="text-[11px] text-muted-foreground mt-1">Refreshing…</p>
</div>
<div v-if="activityExpanded" class="mt-3 space-y-2">
<p v-if="latestBookmark" class="text-xs text-muted-foreground truncate" :title="latestBookmark.title">
Latest bookmark: {{ latestBookmark.title }} · {{ formatRelative(latestBookmark.createdAt) }}
</p>
<div v-for="{ file, progress } in detailProgressRows" :key="file.id" class="rounded-md border border-border px-3 py-2.5 bg-background/60">
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-medium truncate min-w-0">
{{ file.filename ?? `File #${file.id}` }}
<span class="text-muted-foreground font-normal ml-1">({{ (file.format ?? '?').toUpperCase() }})</span>
</p>
<span class="text-xs font-semibold">{{ formatPercent(progress.percentage) }}</span>
</div>
<div class="mt-2 h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full transition-all"
:class="progress.percentage >= 100 ? 'bg-green-500/80' : 'bg-primary/70'"
:style="{ width: `${Math.max(0, Math.min(100, progress.percentage))}%` }"
/>
</div>
</div>
<div v-if="hasKoboSignals" class="rounded-md border border-border px-3 py-2.5 bg-background/60 space-y-1.5">
<p class="text-xs text-muted-foreground">
Kobo status:
<span class="text-foreground">{{ koboState?.readingState?.status ?? 'unknown' }}</span>
<span v-if="koboState?.readingState?.progressPercent != null">· {{ formatPercent(koboState.readingState.progressPercent) }}</span>
</p>
<p v-if="koboState?.readingState?.updatedAt" class="text-xs text-muted-foreground">
Kobo updated {{ formatRelative(koboState.readingState.updatedAt) }}
</p>
<p v-if="koboState?.syncCollections?.length" class="text-xs text-muted-foreground truncate">
Sync collections: {{ koboState.syncCollections.join(', ') }}
</p>
<p v-if="koboState?.snapshot?.inSnapshot" class="text-xs text-muted-foreground">
Snapshot: in sync queue
<span v-if="koboState?.snapshot?.synced != null"> · synced {{ boolState(koboState.snapshot.synced) }}</span>
</p>
<p
v-if="koboState?.snapshot?.pendingDelete || koboState?.snapshot?.isNew || koboState?.snapshot?.removedByDevice"
class="text-xs text-muted-foreground"
>
<span v-if="koboState?.snapshot?.pendingDelete">pending delete</span>
<span v-if="koboState?.snapshot?.isNew" class="ml-2">new</span>
<span v-if="koboState?.snapshot?.removedByDevice" class="ml-2">removed by device</span>
</p>
</div>
</div>
</div>
<!-- Synopsis -->
<div class="mt-6 pt-5 border-t border-border">
<p class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground mb-3">Synopsis</p>