mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(scanner): detect missing books via watcher and propagate to UI
This commit is contained in:
@@ -4,7 +4,7 @@ import { FORMAT_TO_GROUP } from '@projectx/types'
|
||||
import { bookCoverStyle } from '../lib/book-cover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { BookOpen, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2, TriangleAlert } from 'lucide-vue-next'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useCoverVersions } from '../composables/useCoverVersions'
|
||||
|
||||
@@ -32,6 +32,7 @@ const coverSrc = computed(() => coverUrl(props.book.id))
|
||||
|
||||
const coverLoaded = ref(false)
|
||||
const coverFailed = ref(false)
|
||||
const isMissing = computed(() => props.book.status === 'missing')
|
||||
|
||||
function openFile(file: BookFileRef) {
|
||||
router.push({
|
||||
@@ -45,12 +46,13 @@ function openFile(file: BookFileRef) {
|
||||
<template>
|
||||
<div
|
||||
class="group flex flex-col @container"
|
||||
:class="primaryFile ? 'cursor-pointer' : 'cursor-default opacity-60'"
|
||||
@click="primaryFile && openFile(primaryFile)"
|
||||
:class="primaryFile && !isMissing ? 'cursor-pointer' : 'cursor-default'"
|
||||
@click="primaryFile && !isMissing && openFile(primaryFile)"
|
||||
>
|
||||
<!-- Cover -->
|
||||
<div
|
||||
class="relative w-full rounded-sm overflow-hidden shadow-md group-hover:shadow-xl group-hover:scale-[1.02] transition-all duration-150"
|
||||
class="relative w-full rounded-sm overflow-hidden shadow-md transition-all duration-150"
|
||||
:class="isMissing ? 'grayscale opacity-60' : 'group-hover:shadow-xl group-hover:scale-[1.02]'"
|
||||
style="aspect-ratio: 2/3"
|
||||
:style="coverLoaded ? {} : coverStyle"
|
||||
>
|
||||
@@ -75,9 +77,12 @@ function openFile(file: BookFileRef) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Missing overlay -->
|
||||
<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 badge -->
|
||||
<div v-if="isMissing" class="absolute top-1.5 right-1.5 z-20">
|
||||
<span
|
||||
class="flex items-center gap-1 text-[9px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-amber-500/90 text-white backdrop-blur-sm"
|
||||
>
|
||||
<TriangleAlert class="size-2.5 shrink-0" />
|
||||
Missing
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ 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, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { BookOpen, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2, TriangleAlert } from 'lucide-vue-next'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useCoverVersions } from '../composables/useCoverVersions'
|
||||
|
||||
@@ -22,6 +22,7 @@ const seriesLine = computed(() => {
|
||||
return idx != null ? `${props.book.seriesName} #${idx % 1 === 0 ? Math.floor(idx) : idx}` : props.book.seriesName
|
||||
})
|
||||
|
||||
const isMissing = computed(() => props.book.status === 'missing')
|
||||
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
const extraFiles = computed(() => (props.book.files.length > 1 ? props.book.files : []))
|
||||
|
||||
@@ -43,8 +44,8 @@ function openFile(file: BookFileRef) {
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-3 py-2 px-2 rounded-md transition-colors"
|
||||
:class="primaryFile ? 'cursor-pointer hover:bg-muted/50' : 'cursor-default opacity-60'"
|
||||
@click="primaryFile && openFile(primaryFile)"
|
||||
:class="[primaryFile && !isMissing ? 'cursor-pointer hover:bg-muted/50' : 'cursor-default', isMissing ? 'opacity-60 grayscale' : '']"
|
||||
@click="primaryFile && !isMissing && openFile(primaryFile)"
|
||||
>
|
||||
<!-- Cover -->
|
||||
<div class="h-16 w-12 rounded shrink-0 overflow-hidden relative" :style="coverLoaded ? {} : coverStyle">
|
||||
@@ -71,9 +72,10 @@ function openFile(file: BookFileRef) {
|
||||
<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"
|
||||
v-if="isMissing"
|
||||
class="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
<TriangleAlert class="size-3 shrink-0" />
|
||||
Missing
|
||||
</span>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest'
|
||||
import BookCoverCard from '../BookCoverCard.vue'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/features/book/composables/useCoverVersions', () => ({
|
||||
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/features/book/lib/book-cover', () => ({
|
||||
bookCoverStyle: () => ({ background: 'oklch(0.22 0.07 200)', color: 'oklch(0.92 0.03 200)' }),
|
||||
}))
|
||||
|
||||
// Stub complex UI sub-components
|
||||
const globalStubs = {
|
||||
stubs: {
|
||||
DropdownMenu: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuSeparator: { template: '<div />' },
|
||||
},
|
||||
}
|
||||
|
||||
const missingBook: BookCard = {
|
||||
id: 1,
|
||||
status: 'missing',
|
||||
title: 'Gone Book',
|
||||
authors: ['Test Author'],
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
files: [],
|
||||
}
|
||||
|
||||
const presentBook: BookCard = {
|
||||
id: 2,
|
||||
status: 'present',
|
||||
title: 'Available Book',
|
||||
authors: ['Test Author'],
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
files: [{ id: 10, format: 'epub', role: 'primary' }],
|
||||
}
|
||||
|
||||
describe('BookCoverCard — missing state', () => {
|
||||
it('applies grayscale and opacity-60 to the cover container', () => {
|
||||
const wrapper = mount(BookCoverCard, { props: { book: missingBook }, global: globalStubs })
|
||||
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
|
||||
expect(coverDiv.classes()).toContain('grayscale')
|
||||
expect(coverDiv.classes()).toContain('opacity-60')
|
||||
})
|
||||
|
||||
it('does not apply hover-scale to the cover container', () => {
|
||||
const wrapper = mount(BookCoverCard, { props: { book: missingBook }, global: globalStubs })
|
||||
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
|
||||
const classes = coverDiv.classes().join(' ')
|
||||
expect(classes).not.toContain('group-hover:scale-[1.02]')
|
||||
})
|
||||
|
||||
it('renders the amber missing badge with TriangleAlert icon', () => {
|
||||
const wrapper = mount(BookCoverCard, { props: { book: missingBook }, global: globalStubs })
|
||||
const badge = wrapper.find('[class*="bg-amber-500"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text().toLowerCase()).toContain('missing')
|
||||
})
|
||||
|
||||
it('uses cursor-default on the root when book is missing', () => {
|
||||
const wrapper = mount(BookCoverCard, { props: { book: missingBook }, global: globalStubs })
|
||||
expect(wrapper.classes()).toContain('cursor-default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BookCoverCard — present state', () => {
|
||||
it('does not apply grayscale to the cover container', () => {
|
||||
const wrapper = mount(BookCoverCard, { props: { book: presentBook }, global: globalStubs })
|
||||
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
|
||||
expect(coverDiv.classes()).not.toContain('grayscale')
|
||||
})
|
||||
|
||||
it('does not render the missing badge', () => {
|
||||
const wrapper = mount(BookCoverCard, { props: { book: presentBook }, global: globalStubs })
|
||||
expect(wrapper.find('[class*="bg-amber-500"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies hover-scale to cover container when present', () => {
|
||||
const wrapper = mount(BookCoverCard, { props: { book: presentBook }, global: globalStubs })
|
||||
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
|
||||
const classes = coverDiv.classes().join(' ')
|
||||
expect(classes).toContain('group-hover:scale-[1.02]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import BookListRow from '../BookListRow.vue'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/features/book/composables/useCoverVersions', () => ({
|
||||
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/features/book/lib/book-cover', () => ({
|
||||
bookCoverStyle: () => ({ background: 'oklch(0.22 0.07 200)', color: 'oklch(0.92 0.03 200)' }),
|
||||
}))
|
||||
|
||||
const globalStubs = {
|
||||
stubs: {
|
||||
DropdownMenu: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuSeparator: { template: '<div />' },
|
||||
},
|
||||
}
|
||||
|
||||
const missingBook: BookCard = {
|
||||
id: 1,
|
||||
status: 'missing',
|
||||
title: 'Gone Book',
|
||||
authors: ['Test Author'],
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
files: [],
|
||||
}
|
||||
|
||||
const presentBook: BookCard = {
|
||||
id: 2,
|
||||
status: 'present',
|
||||
title: 'Available Book',
|
||||
authors: ['Test Author'],
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
files: [{ id: 10, format: 'epub', role: 'primary' }],
|
||||
}
|
||||
|
||||
describe('BookListRow — missing state', () => {
|
||||
it('applies grayscale and opacity-60 to the root row', () => {
|
||||
const wrapper = mount(BookListRow, { props: { book: missingBook }, global: globalStubs })
|
||||
const root = wrapper.find('.flex.items-center')
|
||||
expect(root.classes()).toContain('grayscale')
|
||||
expect(root.classes()).toContain('opacity-60')
|
||||
})
|
||||
|
||||
it('renders the amber missing badge', () => {
|
||||
const wrapper = mount(BookListRow, { props: { book: missingBook }, global: globalStubs })
|
||||
const badge = wrapper.find('[class*="bg-amber-500"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text().toLowerCase()).toContain('missing')
|
||||
})
|
||||
|
||||
it('does not apply hover:bg-muted on the row when missing', () => {
|
||||
const wrapper = mount(BookListRow, { props: { book: missingBook }, global: globalStubs })
|
||||
const root = wrapper.find('.flex.items-center')
|
||||
expect(root.classes().join(' ')).not.toContain('hover:bg-muted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BookListRow — present state', () => {
|
||||
it('does not apply grayscale to the root row', () => {
|
||||
const wrapper = mount(BookListRow, { props: { book: presentBook }, global: globalStubs })
|
||||
const root = wrapper.find('.flex.items-center')
|
||||
expect(root.classes()).not.toContain('grayscale')
|
||||
})
|
||||
|
||||
it('does not render the missing badge', () => {
|
||||
const wrapper = mount(BookListRow, { props: { book: presentBook }, global: globalStubs })
|
||||
expect(wrapper.find('[class*="bg-amber-500"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies hover:bg-muted/50 when present with a readable file', () => {
|
||||
const wrapper = mount(BookListRow, { props: { book: presentBook }, global: globalStubs })
|
||||
const root = wrapper.find('.flex.items-center')
|
||||
expect(root.classes().join(' ')).toContain('hover:bg-muted')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import DetailsTab from '../detail/tabs/DetailsTab.vue'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }))
|
||||
vi.mock('@/features/book/composables/useCoverVersions', () => ({
|
||||
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/features/book/lib/book-cover', () => ({
|
||||
bookCoverStyle: () => ({ background: 'oklch(0.22 0.07 200)', color: 'oklch(0.92 0.03 200)' }),
|
||||
}))
|
||||
|
||||
const globalStubs = {
|
||||
stubs: {
|
||||
DialogRoot: { template: '<div><slot /></div>' },
|
||||
DialogPortal: { template: '<div><slot /></div>' },
|
||||
DialogOverlay: { template: '<div />' },
|
||||
DialogContent: { template: '<div><slot /></div>' },
|
||||
DialogClose: { template: '<button><slot /></button>' },
|
||||
},
|
||||
}
|
||||
|
||||
function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
return {
|
||||
id: 1,
|
||||
libraryId: 1,
|
||||
status: 'present',
|
||||
title: 'Test Book',
|
||||
subtitle: null,
|
||||
description: null,
|
||||
isbn10: null,
|
||||
isbn13: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
pageCount: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
authors: [],
|
||||
tags: [],
|
||||
files: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('DetailsTab — missing state', () => {
|
||||
it('renders the amber warning banner', () => {
|
||||
const wrapper = mount(DetailsTab, {
|
||||
props: { book: makeBook({ status: 'missing' }) },
|
||||
global: globalStubs,
|
||||
})
|
||||
const banner = wrapper.find('[class*="border-amber-500"]')
|
||||
expect(banner.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows "Files not found" heading in the banner', () => {
|
||||
const wrapper = mount(DetailsTab, {
|
||||
props: { book: makeBook({ status: 'missing' }) },
|
||||
global: globalStubs,
|
||||
})
|
||||
expect(wrapper.text()).toContain('Files not found')
|
||||
})
|
||||
|
||||
it('mentions disk in the banner description', () => {
|
||||
const wrapper = mount(DetailsTab, {
|
||||
props: { book: makeBook({ status: 'missing' }) },
|
||||
global: globalStubs,
|
||||
})
|
||||
expect(wrapper.text().toLowerCase()).toContain('disk')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsTab — present state', () => {
|
||||
it('does not render the warning banner', () => {
|
||||
const wrapper = mount(DetailsTab, {
|
||||
props: { book: makeBook({ status: 'present' }) },
|
||||
global: globalStubs,
|
||||
})
|
||||
expect(wrapper.find('[class*="border-amber-500"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('Files not found')
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, Download, FolderPlus, Trash2, X } from 'lucide-vue-next'
|
||||
import { BookOpen, Download, FolderPlus, Trash2, TriangleAlert, X } from 'lucide-vue-next'
|
||||
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { bookCoverStyle } from '@/features/book/lib/book-cover'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
|
||||
const props = defineProps<{ book: BookDetail }>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const coverLoaded = ref(false)
|
||||
@@ -18,13 +16,12 @@ const coverLightboxOpen = ref(false)
|
||||
const descriptionExpanded = ref(false)
|
||||
|
||||
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
|
||||
|
||||
const { coverUrl } = useCoverVersions()
|
||||
const coverSrc = computed(() => coverUrl(props.book.id, 'cover'))
|
||||
|
||||
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
|
||||
const authorLine = computed(() => props.book.authors.map((a) => a.name).join(', ') || null)
|
||||
const formats = computed(() => [...new Set(props.book.files.map((f) => f.format ?? '?'))])
|
||||
|
||||
const seriesLine = computed(() => {
|
||||
if (!props.book.seriesName) return null
|
||||
@@ -32,8 +29,6 @@ const seriesLine = computed(() => {
|
||||
return idx != null ? `${props.book.seriesName} #${idx % 1 === 0 ? Math.floor(idx) : idx}` : props.book.seriesName
|
||||
})
|
||||
|
||||
const isbn = computed(() => props.book.isbn13 ?? props.book.isbn10 ?? null)
|
||||
|
||||
function openBook() {
|
||||
if (!primaryFile.value) return
|
||||
router.push({
|
||||
@@ -53,11 +48,20 @@ function downloadFile() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="book.status === 'missing'" class="mb-6 flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3">
|
||||
<TriangleAlert class="size-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-amber-600 dark:text-amber-400">Files not found</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
The file(s) for this book can no longer be found on disk. Metadata is still available. Run a library scan to confirm, or remove the record.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<!-- Left column: cover + actions -->
|
||||
<div class="md:w-56 shrink-0 md:sticky md:top-4 md:self-start">
|
||||
<div class="max-w-48 mx-auto md:max-w-none">
|
||||
<!-- Cover -->
|
||||
<div
|
||||
class="w-full rounded-sm overflow-hidden shadow-md cursor-zoom-in"
|
||||
style="aspect-ratio: 2/3"
|
||||
@@ -75,7 +79,6 @@ function downloadFile() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-4 space-y-2">
|
||||
<button
|
||||
class="flex w-full 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"
|
||||
@@ -111,63 +114,89 @@ function downloadFile() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: metadata -->
|
||||
<!-- Right column -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Title block -->
|
||||
<!-- Identity block -->
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ book.title ?? 'Untitled' }}</h1>
|
||||
<p v-if="book.subtitle" class="text-base text-muted-foreground mt-1">{{ book.subtitle }}</p>
|
||||
<p v-if="authorLine" class="text-sm text-foreground/80 mt-2">{{ authorLine }}</p>
|
||||
<p v-if="seriesLine" class="text-sm text-muted-foreground italic mt-0.5">{{ seriesLine }}</p>
|
||||
<p v-if="book.subtitle" class="text-base text-muted-foreground mt-1 leading-snug">{{ book.subtitle }}</p>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex flex-wrap gap-1.5 mt-4">
|
||||
<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="file in book.files"
|
||||
:key="file.id"
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-muted text-muted-foreground"
|
||||
v-for="fmt in formats"
|
||||
:key="fmt"
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded border border-border text-muted-foreground"
|
||||
>
|
||||
{{ file.format ?? '?' }}
|
||||
</span>
|
||||
<span v-if="book.pageCount" class="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{{ book.pageCount }} pages
|
||||
</span>
|
||||
<span v-if="book.publishedYear" class="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{{ book.publishedYear }}
|
||||
</span>
|
||||
<span v-if="book.language" class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{{ book.language }}
|
||||
{{ fmt }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metadata grid -->
|
||||
<dl
|
||||
v-if="book.publisher || book.publishedYear || book.language || book.pageCount || book.isbn13 || book.isbn10"
|
||||
class="mt-5 pt-5 border-t border-border grid grid-cols-2 gap-x-8 gap-y-4"
|
||||
>
|
||||
<div v-if="book.publisher" 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>
|
||||
</div>
|
||||
<div v-if="book.publishedYear">
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="book.language">
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="book.pageCount">
|
||||
<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>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- 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-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
<div v-if="book.tags.length" class="flex flex-wrap gap-1.5 mt-5">
|
||||
<span v-for="tag in book.tags" :key="tag" class="text-xs px-2.5 py-0.5 rounded-full border border-primary/30 text-primary/80">
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Publisher + ISBN -->
|
||||
<div class="mt-3 space-y-0.5">
|
||||
<p v-if="book.publisher" class="text-xs text-muted-foreground">{{ book.publisher }}</p>
|
||||
<p v-if="isbn" class="text-xs text-muted-foreground">ISBN: {{ isbn }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="border-t mt-4 pt-4">
|
||||
<!-- 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>
|
||||
<div v-if="book.description">
|
||||
<div
|
||||
class="text-sm leading-relaxed text-foreground/80 transition-all"
|
||||
:class="descriptionExpanded ? '' : 'line-clamp-4'"
|
||||
:class="descriptionExpanded ? '' : 'line-clamp-6'"
|
||||
v-html="book.description"
|
||||
/>
|
||||
<button
|
||||
class="text-xs text-muted-foreground hover:text-foreground mt-1.5 transition-colors"
|
||||
class="text-xs text-muted-foreground hover:text-foreground mt-2 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>
|
||||
<p v-else class="text-sm text-muted-foreground italic">No description available.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { EventEmitter } from 'events'
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
// ── Hoisted mocks ─────────────────────────────────────────────────────────────
|
||||
// vi.mock factories are hoisted before any const/let, so all references inside
|
||||
// must be self-contained (no outer variables).
|
||||
|
||||
const mockSocket = new EventEmitter()
|
||||
|
||||
vi.mock('@/features/scanner/composables/useScanProgress', () => ({
|
||||
getSocket: () => mockSocket,
|
||||
}))
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: { warning: vi.fn() },
|
||||
}))
|
||||
|
||||
// Import the composable after mocks are in place.
|
||||
import { useBookEvents } from '@/features/book/composables/useBookEvents'
|
||||
|
||||
// Shorthand to the hoisted mock fn — accessed via vi.mocked after module loads.
|
||||
const toastWarning = vi.mocked(toast.warning)
|
||||
|
||||
beforeAll(() => {
|
||||
useBookEvents() // triggers ensureInitialized(), registers socket listeners
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
toastWarning.mockClear()
|
||||
vi.clearAllTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// ── onBookMissing ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('onBookMissing', () => {
|
||||
it('fires the callback with the bookIds array on book:missing event', () => {
|
||||
const { onBookMissing } = useBookEvents()
|
||||
const cb = vi.fn()
|
||||
const cleanup = onBookMissing(cb)
|
||||
|
||||
mockSocket.emit('book:missing', { libraryId: 1, bookIds: [10, 20, 30] })
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1)
|
||||
expect(cb).toHaveBeenCalledWith([10, 20, 30])
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('cleanup function deregisters the callback', () => {
|
||||
const { onBookMissing } = useBookEvents()
|
||||
const cb = vi.fn()
|
||||
const cleanup = onBookMissing(cb)
|
||||
cleanup()
|
||||
|
||||
mockSocket.emit('book:missing', { libraryId: 1, bookIds: [1] })
|
||||
|
||||
expect(cb).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows singular toast for 1 missing book after 1s debounce', () => {
|
||||
const { onBookMissing } = useBookEvents()
|
||||
const cleanup = onBookMissing(() => {})
|
||||
|
||||
mockSocket.emit('book:missing', { libraryId: 1, bookIds: [99] })
|
||||
expect(toastWarning).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
expect(toastWarning).toHaveBeenCalledTimes(1)
|
||||
expect(toastWarning).toHaveBeenCalledWith('1 book is no longer available on disk.')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('batches multiple events into one toast with the total count', () => {
|
||||
const { onBookMissing } = useBookEvents()
|
||||
const cleanup = onBookMissing(() => {})
|
||||
|
||||
mockSocket.emit('book:missing', { libraryId: 1, bookIds: [1, 2, 3] })
|
||||
mockSocket.emit('book:missing', { libraryId: 1, bookIds: [4, 5] })
|
||||
|
||||
expect(toastWarning).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
expect(toastWarning).toHaveBeenCalledTimes(1)
|
||||
expect(toastWarning).toHaveBeenCalledWith('5 books are no longer available on disk.')
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
// ── onBookFileRemoved ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('onBookFileRemoved', () => {
|
||||
it('fires the callback with bookId and fileId on book:file:removed event', () => {
|
||||
const { onBookFileRemoved } = useBookEvents()
|
||||
const cb = vi.fn()
|
||||
const cleanup = onBookFileRemoved(cb)
|
||||
|
||||
mockSocket.emit('book:file:removed', { libraryId: 1, bookId: 42, fileId: 7 })
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1)
|
||||
expect(cb).toHaveBeenCalledWith(42, 7)
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('cleanup deregisters the callback', () => {
|
||||
const { onBookFileRemoved } = useBookEvents()
|
||||
const cb = vi.fn()
|
||||
const cleanup = onBookFileRemoved(cb)
|
||||
cleanup()
|
||||
|
||||
mockSocket.emit('book:file:removed', { libraryId: 1, bookId: 1, fileId: 1 })
|
||||
|
||||
expect(cb).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { BookFileRemovedEvent, BookMissingEvent } from '@projectx/types'
|
||||
import { getSocket } from '@/features/scanner/composables/useScanProgress'
|
||||
|
||||
type BookMissingCallback = (bookIds: number[]) => void
|
||||
type BookFileRemovedCallback = (bookId: number, fileId: number) => void
|
||||
|
||||
const missingCallbacks = new Set<BookMissingCallback>()
|
||||
const fileRemovedCallbacks = new Set<BookFileRemovedCallback>()
|
||||
|
||||
let pendingMissingCount = 0
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function flushMissingToast() {
|
||||
if (pendingMissingCount === 0) return
|
||||
const count = pendingMissingCount
|
||||
pendingMissingCount = 0
|
||||
toastTimer = null
|
||||
toast.warning(count === 1 ? '1 book is no longer available on disk.' : `${count} books are no longer available on disk.`)
|
||||
}
|
||||
|
||||
let initialized = false
|
||||
|
||||
function ensureInitialized() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
const socket = getSocket()
|
||||
|
||||
socket.on('book:missing', (event: BookMissingEvent) => {
|
||||
for (const cb of missingCallbacks) cb(event.bookIds)
|
||||
pendingMissingCount += event.bookIds.length
|
||||
clearTimeout(toastTimer ?? undefined)
|
||||
toastTimer = setTimeout(flushMissingToast, 1000)
|
||||
})
|
||||
|
||||
socket.on('book:file:removed', (event: BookFileRemovedEvent) => {
|
||||
for (const cb of fileRemovedCallbacks) cb(event.bookId, event.fileId)
|
||||
})
|
||||
}
|
||||
|
||||
export function useBookEvents() {
|
||||
ensureInitialized()
|
||||
|
||||
function onBookMissing(cb: BookMissingCallback): () => void {
|
||||
missingCallbacks.add(cb)
|
||||
return () => missingCallbacks.delete(cb)
|
||||
}
|
||||
|
||||
function onBookFileRemoved(cb: BookFileRemovedCallback): () => void {
|
||||
fileRemovedCallbacks.add(cb)
|
||||
return () => fileRemovedCallbacks.delete(cb)
|
||||
}
|
||||
|
||||
return { onBookMissing, onBookFileRemoved }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import BookDetailTabs from '@/features/book/components/detail/BookDetailTabs.vue
|
||||
import DetailsTab from '@/features/book/components/detail/tabs/DetailsTab.vue'
|
||||
import FilesTab from '@/features/book/components/detail/tabs/FilesTab.vue'
|
||||
import { useBookDetail } from '@/features/book/composables/useBookDetail'
|
||||
import { useBookEvents } from '@/features/book/composables/useBookEvents'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -20,6 +21,16 @@ const activeTab = computed(() => (route.query.tab as string) || 'details')
|
||||
const { detail, loading, fetch } = useBookDetail()
|
||||
const { libraries, fetchLibraries } = useLibraries()
|
||||
|
||||
const { onBookMissing, onBookFileRemoved } = useBookEvents()
|
||||
onBookMissing((bookIds) => {
|
||||
if (detail.value && bookIds.includes(detail.value.id)) {
|
||||
detail.value = { ...detail.value, status: 'missing', files: [] }
|
||||
}
|
||||
})
|
||||
onBookFileRemoved((bookId) => {
|
||||
if (detail.value?.id === bookId) fetch(bookId)
|
||||
})
|
||||
|
||||
watch(bookId, (id) => fetch(id), { immediate: true })
|
||||
fetchLibraries()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import ViewHeader from '@/components/ViewHeader.vue'
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
|
||||
import { useBookQuery, type BookCard } from '@/features/book/composables/useBookQuery'
|
||||
import { useBookEvents } from '@/features/book/composables/useBookEvents'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
@@ -29,6 +30,14 @@ const title = computed(() => libraries.value.find((l) => l.id === libraryId.valu
|
||||
|
||||
const { items: books, total, loading, error, filter, sort, hasMore, load, clear } = useBookQuery(libraryId)
|
||||
|
||||
const { onBookMissing } = useBookEvents()
|
||||
onBookMissing((bookIds) => {
|
||||
const missing = new Set(bookIds)
|
||||
for (const book of books.value) {
|
||||
if (missing.has(book.id)) book.status = 'missing'
|
||||
}
|
||||
})
|
||||
|
||||
const filterOpen = ref(false)
|
||||
|
||||
const SORT_FIELD_LABELS: Record<SortField, string> = {
|
||||
|
||||
Reference in New Issue
Block a user