mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): add quick view panel and proportional cover card overlay
This commit is contained in:
@@ -3,11 +3,16 @@ import type { BookCard, BookFileRef } from '@projectx/types'
|
||||
import { bookCoverStyle } from '../lib/book-cover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{ book: BookCard }>()
|
||||
|
||||
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
|
||||
const emit = defineEmits<{ action: [type: BookActionType] }>()
|
||||
|
||||
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
|
||||
const authorLine = computed(() => props.book.authors.join(', ') || null)
|
||||
const seriesLine = computed(() => {
|
||||
@@ -17,8 +22,7 @@ const seriesLine = computed(() => {
|
||||
})
|
||||
|
||||
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
// Only show format chips when there are multiple files
|
||||
const extraFiles = computed(() => (props.book.files.length > 1 ? props.book.files : []))
|
||||
const extraFiles = computed(() => props.book.files.filter((f) => f !== primaryFile.value))
|
||||
|
||||
const coverLoaded = ref(false)
|
||||
const coverFailed = ref(false)
|
||||
@@ -34,7 +38,7 @@ function openFile(file: BookFileRef) {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group flex flex-col gap-1.5"
|
||||
class="group flex flex-col @container"
|
||||
:class="primaryFile ? 'cursor-pointer' : 'cursor-default opacity-60'"
|
||||
@click="primaryFile && openFile(primaryFile)"
|
||||
>
|
||||
@@ -56,7 +60,7 @@ function openFile(file: BookFileRef) {
|
||||
/>
|
||||
|
||||
<!-- Series badge -->
|
||||
<div v-if="seriesLine" class="absolute top-1.5 left-1.5 right-1.5">
|
||||
<div v-if="seriesLine" class="absolute top-1.5 left-1.5 right-1.5 z-10">
|
||||
<span
|
||||
class="text-[8px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-black/30 backdrop-blur-sm line-clamp-1"
|
||||
:style="{ color: coverStyle.color }"
|
||||
@@ -65,28 +69,14 @@ function openFile(file: BookFileRef) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Format chips — only shown when book has multiple files -->
|
||||
<div v-if="extraFiles.length > 0" class="absolute bottom-1.5 right-1.5 flex flex-col gap-1 items-end" @click.stop>
|
||||
<button
|
||||
v-for="file in extraFiles"
|
||||
:key="file.id"
|
||||
class="text-[9px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-black/50 backdrop-blur-sm hover:bg-black/70 transition-colors"
|
||||
:class="file.role === 'primary' ? 'text-white' : 'text-white/70'"
|
||||
:title="`Open as ${file.format?.toUpperCase() ?? 'unknown'}`"
|
||||
@click="openFile(file)"
|
||||
>
|
||||
{{ file.format ?? '?' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Missing overlay -->
|
||||
<div v-if="book.status === 'missing'" class="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<div v-if="book.status === 'missing'" class="absolute inset-0 bg-black/60 flex items-center justify-center z-10">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-destructive-foreground bg-destructive px-2 py-0.5 rounded">
|
||||
Missing
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Title + author (shown when no real cover) -->
|
||||
<!-- Title + author (no-cover fallback, always visible when cover absent) -->
|
||||
<div v-if="!coverLoaded" class="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
|
||||
<p class="text-xs font-bold leading-tight line-clamp-3" :style="{ color: coverStyle.color }">
|
||||
{{ book.title ?? '-' }}
|
||||
@@ -95,16 +85,72 @@ function openFile(file: BookFileRef) {
|
||||
{{ authorLine }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text below -->
|
||||
<div class="px-0.5">
|
||||
<p class="text-xs font-medium text-foreground truncate leading-tight">
|
||||
{{ book.title ?? '-' }}
|
||||
</p>
|
||||
<p v-if="authorLine" class="text-[11px] text-muted-foreground truncate">
|
||||
{{ authorLine }}
|
||||
</p>
|
||||
<!-- Hover overlay -->
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col p-2 bg-black/60 backdrop-blur-[1px] opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Center: primary action buttons -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-[18cqi]">
|
||||
<button
|
||||
v-if="primaryFile"
|
||||
class="p-[7cqi] rounded-full bg-primary/50 hover:bg-primary transition-colors text-white"
|
||||
title="Read"
|
||||
@click="openFile(primaryFile)"
|
||||
>
|
||||
<BookOpen class="size-[14cqi]" />
|
||||
</button>
|
||||
<button
|
||||
class="p-[7cqi] rounded-full bg-primary/70 hover:bg-primary transition-colors text-white"
|
||||
title="Quick view"
|
||||
@click="emit('action', 'quick-view')"
|
||||
>
|
||||
<PanelRight class="size-[14cqi]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: title/author + kebab -->
|
||||
<div class="flex items-end justify-between gap-2">
|
||||
<div v-if="coverLoaded" class="min-w-0 flex-1">
|
||||
<p class="text-xs font-semibold text-white leading-tight line-clamp-2">{{ book.title ?? '-' }}</p>
|
||||
<p v-if="authorLine" class="text-[10px] text-white/70 truncate mt-0.5">{{ authorLine }}</p>
|
||||
</div>
|
||||
<div v-else class="flex-1" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="p-1.5 rounded-full bg-black/40 hover:bg-white/20 transition-colors text-white shrink-0">
|
||||
<MoreHorizontal class="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem v-if="primaryFile" @click="openFile(primaryFile)">
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
{{ extraFiles.length > 0 ? `Open as ${primaryFile.format?.toUpperCase() ?? 'primary'}` : 'Open' }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-for="file in extraFiles" :key="file.id" @click="openFile(file)">
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
Open as {{ file.format?.toUpperCase() ?? '?' }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="emit('action', 'edit-metadata')">
|
||||
<Pencil class="size-4 mr-2" />
|
||||
Edit Metadata
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="emit('action', 'add-to-collection')">
|
||||
<FolderPlus class="size-4 mr-2" />
|
||||
Add to Collection
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="emit('action', 'delete')">
|
||||
<Trash2 class="size-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,11 +3,16 @@ import type { BookCard, BookFileRef } from '@projectx/types'
|
||||
import { bookCoverStyle } from '../lib/book-cover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{ book: BookCard }>()
|
||||
|
||||
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
|
||||
const emit = defineEmits<{ action: [type: BookActionType] }>()
|
||||
|
||||
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
|
||||
const authorLine = computed(() => props.book.authors.join(', ') || null)
|
||||
const seriesLine = computed(() => {
|
||||
@@ -58,24 +63,62 @@ function openFile(file: BookFileRef) {
|
||||
<span v-if="seriesLine" class="text-xs text-muted-foreground/70 truncate italic">{{ seriesLine }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Right badges -->
|
||||
<div class="flex flex-col items-end gap-1 shrink-0" @click.stop>
|
||||
<span
|
||||
v-if="book.status === 'missing'"
|
||||
class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-destructive text-destructive-foreground"
|
||||
>
|
||||
Missing
|
||||
</span>
|
||||
<!-- Right badges + actions -->
|
||||
<div class="flex items-center gap-2 shrink-0" @click.stop>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<span
|
||||
v-if="book.status === 'missing'"
|
||||
class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-destructive text-destructive-foreground"
|
||||
>
|
||||
Missing
|
||||
</span>
|
||||
<button
|
||||
v-for="file in extraFiles"
|
||||
:key="file.id"
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/70 transition-colors"
|
||||
:class="file.role === 'primary' ? 'text-foreground' : 'text-muted-foreground'"
|
||||
:title="`Open as ${file.format?.toUpperCase() ?? 'unknown'}`"
|
||||
@click="openFile(file)"
|
||||
>
|
||||
{{ file.format ?? '?' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="file in extraFiles"
|
||||
:key="file.id"
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/70 transition-colors"
|
||||
:class="file.role === 'primary' ? 'text-foreground' : 'text-muted-foreground'"
|
||||
:title="`Open as ${file.format?.toUpperCase() ?? 'unknown'}`"
|
||||
@click="openFile(file)"
|
||||
class="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Quick view"
|
||||
@click="emit('action', 'quick-view')"
|
||||
>
|
||||
{{ file.format ?? '?' }}
|
||||
<PanelRight class="size-4" />
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground">
|
||||
<MoreHorizontal class="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="primaryFile && openFile(primaryFile)">
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="emit('action', 'edit-metadata')">
|
||||
<Pencil class="size-4 mr-2" />
|
||||
Edit Metadata
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="emit('action', 'add-to-collection')">
|
||||
<FolderPlus class="size-4 mr-2" />
|
||||
Add to Collection
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="emit('action', 'delete')">
|
||||
<Trash2 class="size-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, FolderPlus, Pencil, Trash2, X } from 'lucide-vue-next'
|
||||
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { DialogRoot, DialogContent, DialogPortal, DialogOverlay, DialogClose } from 'reka-ui'
|
||||
import { useBookDetail } from '../composables/useBookDetail'
|
||||
import { bookCoverStyle } from '../lib/book-cover'
|
||||
|
||||
const props = defineProps<{ bookId: number | null; open: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
action: [type: 'edit-metadata' | 'add-to-collection' | 'delete']
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const { detail, loading, fetch } = useBookDetail()
|
||||
|
||||
const coverLoaded = ref(false)
|
||||
const coverFailed = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.bookId,
|
||||
(id) => {
|
||||
if (id !== null) {
|
||||
coverLoaded.value = false
|
||||
coverFailed.value = false
|
||||
descriptionExpanded.value = false
|
||||
fetch(id)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const coverStyle = computed(() => (detail.value ? bookCoverStyle(detail.value.title ?? String(detail.value.id)) : {}))
|
||||
|
||||
const seriesLine = computed(() => {
|
||||
if (!detail.value?.seriesName) return null
|
||||
const idx = detail.value.seriesIndex
|
||||
return idx != null ? `${detail.value.seriesName} #${idx % 1 === 0 ? Math.floor(idx) : idx}` : detail.value.seriesName
|
||||
})
|
||||
|
||||
const authorLine = computed(() => detail.value?.authors.map((a) => a.name).join(', ') ?? null)
|
||||
|
||||
const descriptionExpanded = ref(false)
|
||||
const coverLightboxOpen = ref(false)
|
||||
|
||||
const primaryFile = computed(() => detail.value?.files.find((f) => f.role === 'primary') ?? detail.value?.files[0] ?? null)
|
||||
|
||||
function openBook() {
|
||||
if (!primaryFile.value || !detail.value) return
|
||||
router.push({
|
||||
name: 'reader',
|
||||
params: { bookId: detail.value.id, fileId: primaryFile.value.id },
|
||||
query: { format: primaryFile.value.format ?? 'epub' },
|
||||
})
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
|
||||
<SheetContent side="right" class="sm:max-w-[400px] p-0 overflow-hidden">
|
||||
<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="aspect-ratio: 2/3" />
|
||||
<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 -->
|
||||
<div
|
||||
class="w-24 shrink-0 rounded overflow-hidden shadow-md cursor-zoom-in"
|
||||
style="aspect-ratio: 2/3"
|
||||
:style="coverLoaded ? {} : coverStyle"
|
||||
@click="coverLoaded && !coverFailed && (coverLightboxOpen = true)"
|
||||
>
|
||||
<img
|
||||
v-if="!coverFailed"
|
||||
:src="`/api/books/${detail.id}/cover`"
|
||||
class="w-full h-full object-cover transition-opacity duration-200"
|
||||
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
|
||||
:alt="detail.title ?? ''"
|
||||
@load="coverLoaded = true"
|
||||
@error="coverFailed = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0 pr-2">
|
||||
<SheetTitle class="text-sm font-bold leading-snug line-clamp-3">
|
||||
{{ detail.title ?? 'Untitled' }}
|
||||
</SheetTitle>
|
||||
<p v-if="detail.subtitle" class="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{{ detail.subtitle }}
|
||||
</p>
|
||||
<p v-if="authorLine" class="text-xs text-foreground/80 mt-2">{{ authorLine }}</p>
|
||||
<p v-if="seriesLine" class="text-xs text-muted-foreground mt-0.5 italic">{{ seriesLine }}</p>
|
||||
</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">
|
||||
<!-- Meta chips -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="file in detail.files"
|
||||
:key="file.id"
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-muted text-muted-foreground"
|
||||
>
|
||||
{{ file.format ?? '?' }}
|
||||
</span>
|
||||
<span v-if="detail.pageCount" class="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{{ detail.pageCount }} pages
|
||||
</span>
|
||||
<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">
|
||||
{{ detail.language }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Publisher -->
|
||||
<p v-if="detail.publisher" class="text-xs text-muted-foreground">
|
||||
{{ detail.publisher }}
|
||||
</p>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="detail.tags.length" class="flex flex-wrap gap-1.5">
|
||||
<span v-for="tag in detail.tags" :key="tag" class="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="border-t pt-4">
|
||||
<div v-if="detail.description">
|
||||
<p class="text-sm leading-relaxed text-foreground/80 transition-all" :class="descriptionExpanded ? '' : 'line-clamp-4'">
|
||||
{{ detail.description }}
|
||||
</p>
|
||||
<button
|
||||
class="text-xs text-muted-foreground hover:text-foreground mt-1.5 transition-colors"
|
||||
@click="descriptionExpanded = !descriptionExpanded"
|
||||
>
|
||||
{{ descriptionExpanded ? 'Show less' : 'Show more' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-xs text-muted-foreground italic">No description available.</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"
|
||||
@click="openBook"
|
||||
>
|
||||
<BookOpen class="size-4" />
|
||||
Read
|
||||
</button>
|
||||
<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"
|
||||
title="Edit Metadata"
|
||||
@click="emit('action', 'edit-metadata')"
|
||||
>
|
||||
<Pencil class="size-3.5" />
|
||||
</button>
|
||||
<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"
|
||||
title="Add to Collection"
|
||||
@click="emit('action', 'add-to-collection')"
|
||||
>
|
||||
<FolderPlus class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center h-9 px-3 rounded-md text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||
title="Delete"
|
||||
@click="emit('action', 'delete')"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<!-- Cover lightbox -->
|
||||
<DialogRoot :open="coverLightboxOpen" @update:open="coverLightboxOpen = $event">
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 bg-black/80 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"
|
||||
>
|
||||
<img
|
||||
v-if="detail"
|
||||
:src="`/api/books/${detail.id}/cover`"
|
||||
: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>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
|
||||
export function useBookDetail() {
|
||||
const detail = ref<BookDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetch(bookId: number) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
detail.value = null
|
||||
try {
|
||||
const res = await api(`/api/books/${bookId}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
detail.value = await res.json()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load book'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { detail, loading, error, fetch }
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowDownAZ, ArrowUpAZ, Filter, X } from 'lucide-vue-next'
|
||||
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
|
||||
import BookListRow from '@/features/book/components/BookListRow.vue'
|
||||
import BookQuickView from '@/features/book/components/BookQuickView.vue'
|
||||
import BookFilterBuilder from '@/features/book/components/BookFilterBuilder.vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import ViewHeader from '@/components/ViewHeader.vue'
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import SettingsDrawer from '@/features/settings/SettingsDrawer.vue'
|
||||
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
|
||||
import { useBookQuery } from '@/features/book/composables/useBookQuery'
|
||||
import { useBookQuery, type BookCard } from '@/features/book/composables/useBookQuery'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
@@ -103,6 +104,20 @@ watch(filter, () => load(true), { deep: true })
|
||||
watch(loading, (isLoading) => {
|
||||
if (!isLoading) loadIfSentinelVisible()
|
||||
})
|
||||
|
||||
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
|
||||
|
||||
const quickViewBookId = ref<number | null>(null)
|
||||
const quickViewOpen = ref(false)
|
||||
|
||||
function handleBookAction(book: BookCard, action: BookActionType) {
|
||||
if (action === 'quick-view') {
|
||||
quickViewBookId.value = book.id
|
||||
quickViewOpen.value = true
|
||||
return
|
||||
}
|
||||
// TODO: implement remaining actions
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -179,12 +194,12 @@ watch(loading, (isLoading) => {
|
||||
class="grid"
|
||||
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))`, gap: `${gridGap}px` }"
|
||||
>
|
||||
<BookCoverCard v-for="book in books" :key="book.id" :book="book" />
|
||||
<BookCoverCard v-for="book in books" :key="book.id" :book="book" @action="handleBookAction(book, $event)" />
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<div v-else class="flex flex-col divide-y divide-border">
|
||||
<BookListRow v-for="book in books" :key="book.id" :book="book" />
|
||||
<BookListRow v-for="book in books" :key="book.id" :book="book" @action="handleBookAction(book, $event)" />
|
||||
</div>
|
||||
|
||||
<div ref="sentinel" class="h-8 mt-4 flex items-center justify-center">
|
||||
@@ -194,4 +209,11 @@ watch(loading, (isLoading) => {
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
<BookQuickView
|
||||
:book-id="quickViewBookId"
|
||||
:open="quickViewOpen"
|
||||
@update:open="quickViewOpen = $event"
|
||||
@action="quickViewBookId !== null && handleBookAction({ id: quickViewBookId } as BookCard, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+43
-17
@@ -1,22 +1,48 @@
|
||||
export type BookFileRef = {
|
||||
id: number
|
||||
format: string | null
|
||||
role: string
|
||||
}
|
||||
id: number;
|
||||
format: string | null;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export type BookCard = {
|
||||
id: number
|
||||
status: string
|
||||
title: string | null
|
||||
authors: string[]
|
||||
seriesName: string | null
|
||||
seriesIndex: number | null
|
||||
files: BookFileRef[]
|
||||
}
|
||||
id: number;
|
||||
status: string;
|
||||
title: string | null;
|
||||
authors: string[];
|
||||
seriesName: string | null;
|
||||
seriesIndex: number | null;
|
||||
files: BookFileRef[];
|
||||
};
|
||||
|
||||
export type BookDetailFile = {
|
||||
id: number;
|
||||
format: string | null;
|
||||
role: string;
|
||||
sizeBytes: number | null;
|
||||
};
|
||||
|
||||
export type BookDetail = {
|
||||
id: number;
|
||||
status: string;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
description: string | null;
|
||||
isbn10: string | null;
|
||||
isbn13: string | null;
|
||||
publisher: string | null;
|
||||
publishedYear: number | null;
|
||||
language: string | null;
|
||||
pageCount: number | null;
|
||||
seriesName: string | null;
|
||||
seriesIndex: number | null;
|
||||
authors: { id: number; name: string; sortName: string | null }[];
|
||||
tags: string[];
|
||||
files: BookDetailFile[];
|
||||
};
|
||||
|
||||
export type BooksPage = {
|
||||
items: BookCard[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
items: BookCard[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1769224478680,
|
||||
"when": 1769335416966,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user