mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: harden nested audio metadata contract and UI consistency
This commit is contained in:
@@ -390,7 +390,7 @@ async function handleSetStatus(status: ReadStatus) {
|
||||
</p>
|
||||
<div v-else class="flex-1" />
|
||||
|
||||
<div v-if="!showAuthorOnHover" class="shrink-0">
|
||||
<div class="shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="p-1.5 rounded-full bg-black/40 hover:bg-white/20 transition-colors text-white shrink-0">
|
||||
@@ -506,125 +506,10 @@ async function handleSetStatus(status: ReadStatus) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAuthorOnHover" class="flex items-center justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<button class="text-[10px] text-white/70 truncate hover:underline text-left block w-full" @click.stop="openAuthorBrowse">
|
||||
{{ authorLine }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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="openableFiles.length <= 1 && primaryFile && !isMissing" @click="openFile(primaryFile)">
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub v-else-if="openableFiles.length > 1 && !isMissing">
|
||||
<DropdownMenuSubTrigger>
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
Open
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="openFile(file)">
|
||||
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
|
||||
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
|
||||
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<!-- Download submenu -->
|
||||
<DropdownMenuItem
|
||||
v-if="hasPermission('library_download') && openableFiles.length === 1 && primaryFile"
|
||||
@click="handleDownloadFile(primaryFile)"
|
||||
>
|
||||
<Download class="size-4 mr-2" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub v-else-if="hasPermission('library_download') && openableFiles.length > 1">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Download class="size-4 mr-2" />
|
||||
Download
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="handleDownloadFile(file)">
|
||||
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
|
||||
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
|
||||
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="handleExportAll"> All formats (ZIP) </DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuItem @click="router.push({ name: 'book-detail', params: { bookId: book.id } })">
|
||||
<ExternalLink class="size-4 mr-2" />
|
||||
Book Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator v-if="hasPermission('library_edit_metadata')" />
|
||||
<DropdownMenuSub v-if="hasPermission('library_edit_metadata')">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Pencil class="size-4 mr-2" />
|
||||
Metadata
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem @click="router.push({ name: 'book-detail', params: { bookId: book.id }, query: { tab: 'edit' } })">
|
||||
<Pencil class="size-4 mr-2" />
|
||||
Edit Metadata
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem :disabled="anyRefreshing" @click="refreshWithFeedback(book.id)">
|
||||
<Loader2 v-if="anyRefreshing" class="size-4 mr-2 animate-spin" />
|
||||
<RefreshCw v-else class="size-4 mr-2" />
|
||||
Refresh Metadata
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem :disabled="reExtractingCover" @click="reExtractCover()">
|
||||
<Loader2 v-if="reExtractingCover" class="size-4 mr-2 animate-spin" />
|
||||
<Image v-else class="size-4 mr-2" />
|
||||
Regenerate Cover
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem @click="emit('action', 'add-to-collection')">
|
||||
<FolderPlus class="size-4 mr-2" />
|
||||
Add to Collection
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<component
|
||||
:is="STATUS_ICONS[localReadStatus ?? 'unread']"
|
||||
class="size-4 mr-2"
|
||||
:class="STATUS_COLORS[localReadStatus ?? 'unread']"
|
||||
/>
|
||||
Set Status
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem v-for="opt in STATUS_OPTIONS" :key="opt.value" @click="handleSetStatus(opt.value)">
|
||||
<component :is="STATUS_ICONS[opt.value]" class="size-4 mr-2" :class="STATUS_COLORS[opt.value]" />
|
||||
{{ opt.label }}
|
||||
<Check v-if="localReadStatus === opt.value" class="size-3 ml-auto text-primary" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem v-if="hasPermission('email_send')" @click="showSendDialog = true">
|
||||
<Send class="size-4 mr-2" />
|
||||
Send via Email
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
v-if="hasPermission('library_delete_books')"
|
||||
class="text-destructive focus:text-destructive"
|
||||
@click="emit('action', 'delete')"
|
||||
>
|
||||
<Trash2 class="size-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div v-if="showAuthorOnHover" class="min-w-0">
|
||||
<button class="text-[10px] text-white/70 truncate hover:underline text-left block w-full" @click.stop="openAuthorBrowse">
|
||||
{{ authorLine }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,6 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
coverSource: null,
|
||||
providerIds: {},
|
||||
authors: [],
|
||||
narrators: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
files: [],
|
||||
@@ -85,9 +84,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
lastWrittenAt: null,
|
||||
metadataScore: null,
|
||||
readStatus: null,
|
||||
durationSeconds: null,
|
||||
abridged: false,
|
||||
chapters: null,
|
||||
audioMetadata: null,
|
||||
formatPriority: [],
|
||||
comicMetadata: null,
|
||||
...overrides,
|
||||
|
||||
@@ -246,7 +246,7 @@ const openableFiles = computed(() => {
|
||||
})
|
||||
const hasMultipleFiles = computed(() => openableFiles.value.length > 1)
|
||||
const authorLine = computed(() => props.book.authors.map((a) => a.name).join(', ') || null)
|
||||
const narratorLine = computed(() => props.book.narrators?.map((n) => n.name).join(', ') || null)
|
||||
const narratorLine = computed(() => props.book.audioMetadata?.narrators?.map((n) => n.name).join(', ') || null)
|
||||
const formats = computed(() => {
|
||||
const all = [...new Set(props.book.files.filter((f) => f.format && FORMAT_TO_GROUP[f.format]).map((f) => f.format!))]
|
||||
const priority = props.book.formatPriority
|
||||
@@ -1025,13 +1025,13 @@ watch(
|
||||
<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.durationSeconds != null">
|
||||
<div v-if="book.audioMetadata?.durationSeconds != null">
|
||||
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Duration</dt>
|
||||
<dd class="text-sm text-foreground mt-0.5">{{ formatDuration(book.durationSeconds) }}</dd>
|
||||
<dd class="text-sm text-foreground mt-0.5">{{ formatDuration(book.audioMetadata.durationSeconds) }}</dd>
|
||||
</div>
|
||||
<div v-if="book.durationSeconds != null">
|
||||
<div v-if="book.audioMetadata?.durationSeconds != null">
|
||||
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Edition</dt>
|
||||
<dd class="text-sm text-foreground mt-0.5">{{ book.abridged ? 'Abridged' : 'Unabridged' }}</dd>
|
||||
<dd class="text-sm text-foreground mt-0.5">{{ book.audioMetadata.abridged ? 'Abridged' : 'Unabridged' }}</dd>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">ISBN</dt>
|
||||
|
||||
@@ -152,6 +152,11 @@ async function autoFill() {
|
||||
if (preview.comicMetadata.teams !== undefined) form.comicTeams = preview.comicMetadata.teams
|
||||
if (preview.comicMetadata.locations !== undefined) form.comicLocations = preview.comicMetadata.locations
|
||||
}
|
||||
if (preview.audioMetadata) {
|
||||
if (preview.audioMetadata.narrators !== undefined) form.narrators = preview.audioMetadata.narrators
|
||||
if (preview.audioMetadata.durationSeconds !== undefined) form.durationSeconds = preview.audioMetadata.durationSeconds
|
||||
if (preview.audioMetadata.abridged !== undefined) form.abridged = !!preview.audioMetadata.abridged
|
||||
}
|
||||
if (preview.coverUrl) coverPanel.value?.setUrl(preview.coverUrl)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,9 +37,9 @@ const currentSource = computed<MetadataSource>(() => ({
|
||||
isbn13: props.book.isbn13,
|
||||
authors: props.book.authors.map((a) => a.name),
|
||||
genres: props.book.genres,
|
||||
narrators: props.book.narrators.map((n) => n.name),
|
||||
durationSeconds: props.book.durationSeconds,
|
||||
abridged: props.book.abridged,
|
||||
narrators: props.book.audioMetadata?.narrators.map((n) => n.name) ?? [],
|
||||
durationSeconds: props.book.audioMetadata?.durationSeconds ?? null,
|
||||
abridged: props.book.audioMetadata?.abridged ?? null,
|
||||
}))
|
||||
|
||||
const {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import { useMetadataEditor } from '../useMetadataEditor'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}))
|
||||
|
||||
function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
return {
|
||||
id: 1,
|
||||
libraryId: 1,
|
||||
libraryName: 'Test Library',
|
||||
status: 'present',
|
||||
folderPath: '/books',
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
title: 'Test Book',
|
||||
subtitle: null,
|
||||
description: null,
|
||||
isbn10: null,
|
||||
isbn13: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
pageCount: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
rating: null,
|
||||
coverSource: null,
|
||||
providerIds: {},
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
files: [
|
||||
{
|
||||
id: 11,
|
||||
format: 'epub',
|
||||
role: 'primary',
|
||||
sizeBytes: 10,
|
||||
absolutePath: '/books/test.epub',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
filename: 'test.epub',
|
||||
durationSeconds: null,
|
||||
},
|
||||
],
|
||||
lastWrittenAt: null,
|
||||
metadataScore: null,
|
||||
readStatus: null,
|
||||
audioMetadata: null,
|
||||
formatPriority: [],
|
||||
comicMetadata: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMetadataEditor', () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset()
|
||||
})
|
||||
|
||||
it('omits audioMetadata when book has no audio files', async () => {
|
||||
const book = makeBook()
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => book })
|
||||
|
||||
const { load, save } = useMetadataEditor()
|
||||
load(book)
|
||||
await save(book.id)
|
||||
|
||||
const [, req] = apiMock.mock.calls[0] as [string, RequestInit]
|
||||
const payload = JSON.parse(String(req.body)) as Record<string, unknown>
|
||||
expect(payload.audioMetadata).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes audioMetadata when book has audio files', async () => {
|
||||
const book = makeBook({
|
||||
files: [
|
||||
{
|
||||
id: 12,
|
||||
format: 'm4b',
|
||||
role: 'primary',
|
||||
sizeBytes: 10,
|
||||
absolutePath: '/books/test.m4b',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
filename: 'test.m4b',
|
||||
durationSeconds: 3600,
|
||||
},
|
||||
],
|
||||
audioMetadata: {
|
||||
narrators: [{ id: 2, name: 'Narrator One', sortName: null, displayOrder: 0 }],
|
||||
durationSeconds: 3600,
|
||||
abridged: false,
|
||||
chapters: null,
|
||||
},
|
||||
})
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => book })
|
||||
|
||||
const { load, save } = useMetadataEditor()
|
||||
load(book)
|
||||
await save(book.id)
|
||||
|
||||
const [, req] = apiMock.mock.calls[0] as [string, RequestInit]
|
||||
const payload = JSON.parse(String(req.body)) as {
|
||||
audioMetadata?: { narrators?: string[]; durationSeconds?: number | null; abridged?: boolean }
|
||||
}
|
||||
expect(payload.audioMetadata).toEqual({
|
||||
narrators: ['Narrator One'],
|
||||
durationSeconds: 3600,
|
||||
abridged: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import { FORMAT_TO_GROUP, type BookDetail } from '@projectx/types'
|
||||
|
||||
export function useMetadataEditor() {
|
||||
const saving = ref(false)
|
||||
@@ -47,6 +47,7 @@ export function useMetadataEditor() {
|
||||
})
|
||||
|
||||
const snapshot = ref(JSON.stringify(form))
|
||||
const includeAudioMetadata = ref(false)
|
||||
|
||||
const isDirty = computed(() => JSON.stringify(form) !== snapshot.value)
|
||||
|
||||
@@ -66,9 +67,9 @@ export function useMetadataEditor() {
|
||||
form.authors = book.authors.map((a) => a.name)
|
||||
form.genres = [...book.genres]
|
||||
form.tags = [...book.tags]
|
||||
form.narrators = book.narrators?.map((n) => n.name) ?? []
|
||||
form.durationSeconds = book.durationSeconds ?? null
|
||||
form.abridged = book.abridged ?? false
|
||||
form.narrators = book.audioMetadata?.narrators?.map((n) => n.name) ?? []
|
||||
form.durationSeconds = book.audioMetadata?.durationSeconds ?? null
|
||||
form.abridged = book.audioMetadata?.abridged ?? false
|
||||
form.googleBooksId = book.providerIds.google ?? null
|
||||
form.goodreadsId = book.providerIds.goodreads ?? null
|
||||
form.amazonId = book.providerIds.amazon ?? null
|
||||
@@ -89,6 +90,7 @@ export function useMetadataEditor() {
|
||||
form.comicCharacters = cm?.characters ?? []
|
||||
form.comicTeams = cm?.teams ?? []
|
||||
form.comicLocations = cm?.locations ?? []
|
||||
includeAudioMetadata.value = book.audioMetadata != null || book.files.some((f) => f.format != null && FORMAT_TO_GROUP[f.format] === 'audio')
|
||||
snapshot.value = JSON.stringify(form)
|
||||
error.value = null
|
||||
}
|
||||
@@ -112,9 +114,12 @@ export function useMetadataEditor() {
|
||||
comicCharacters,
|
||||
comicTeams,
|
||||
comicLocations,
|
||||
narrators,
|
||||
durationSeconds,
|
||||
abridged,
|
||||
...rest
|
||||
} = form
|
||||
return {
|
||||
const payload = {
|
||||
...rest,
|
||||
comicMetadata: {
|
||||
issueNumber: comicIssueNumber ?? undefined,
|
||||
@@ -130,6 +135,17 @@ export function useMetadataEditor() {
|
||||
locations: comicLocations,
|
||||
},
|
||||
}
|
||||
if (includeAudioMetadata.value) {
|
||||
return {
|
||||
...payload,
|
||||
audioMetadata: {
|
||||
narrators,
|
||||
durationSeconds,
|
||||
abridged,
|
||||
},
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
async function save(bookId: number): Promise<BookDetail | null> {
|
||||
|
||||
@@ -25,6 +25,12 @@ export interface MetadataRefreshPreview {
|
||||
itunesId?: string | null
|
||||
audibleId?: string | null
|
||||
comicvineId?: string | null
|
||||
audioMetadata?: {
|
||||
narrators?: string[]
|
||||
durationSeconds?: number | null
|
||||
abridged?: boolean | null
|
||||
chapters?: { title: string; startMs: number; durationMs?: number | null }[]
|
||||
}
|
||||
comicMetadata?: ComicMetadataFields
|
||||
}
|
||||
|
||||
|
||||
@@ -401,7 +401,7 @@ function setSleepTimer(minutes: number) {
|
||||
|
||||
function setEndOfChapterSleep() {
|
||||
// If there are no chapters to watch, fall back to a 30-minute timer.
|
||||
if (!detail.value?.chapters?.length) {
|
||||
if (!detail.value?.audioMetadata?.chapters?.length) {
|
||||
setSleepTimer(30)
|
||||
return
|
||||
}
|
||||
@@ -467,8 +467,8 @@ const progressPct = computed(() => {
|
||||
})
|
||||
|
||||
const chapterTicks = computed(() => {
|
||||
if (!detail.value?.chapters?.length || !totalBookDuration.value) return []
|
||||
return detail.value.chapters
|
||||
if (!detail.value?.audioMetadata?.chapters?.length || !totalBookDuration.value) return []
|
||||
return detail.value.audioMetadata.chapters
|
||||
.filter((ch) => ch.startMs > 0)
|
||||
.map((ch) => ({
|
||||
startMs: ch.startMs,
|
||||
@@ -487,7 +487,7 @@ const bookmarkTicks = computed(() => {
|
||||
})
|
||||
|
||||
const chapterDurations = computed<number[]>(() => {
|
||||
const chapters = detail.value?.chapters
|
||||
const chapters = detail.value?.audioMetadata?.chapters
|
||||
if (!chapters?.length) return []
|
||||
return chapters.map((ch, i) => {
|
||||
const nextStartMs = chapters[i + 1]?.startMs ?? totalBookDuration.value * 1000
|
||||
@@ -498,7 +498,7 @@ const chapterDurations = computed<number[]>(() => {
|
||||
const SPEEDS = [0.75, 1.0, 1.25, 1.5, 2.0] as const
|
||||
|
||||
const currentChapter = computed<AudiobookChapter | null>(() => {
|
||||
const chapters = detail.value?.chapters
|
||||
const chapters = detail.value?.audioMetadata?.chapters
|
||||
if (!chapters?.length) return null
|
||||
const pos = absolutePositionMs.value
|
||||
let current: AudiobookChapter | null = null
|
||||
@@ -511,7 +511,7 @@ const currentChapter = computed<AudiobookChapter | null>(() => {
|
||||
|
||||
const scrubberHoverChapter = computed<AudiobookChapter | null>(() => {
|
||||
if (scrubberHoverSeconds.value === null) return null
|
||||
const chapters = detail.value?.chapters
|
||||
const chapters = detail.value?.audioMetadata?.chapters
|
||||
if (!chapters?.length) return null
|
||||
const posMs = scrubberHoverSeconds.value * 1000
|
||||
let cur: AudiobookChapter | null = null
|
||||
@@ -570,8 +570,8 @@ onMounted(async () => {
|
||||
])
|
||||
if (!mounted) return
|
||||
detail.value = detailRes
|
||||
if (detailRes.chapters) {
|
||||
detailRes.chapters.sort((a, b) => a.startMs - b.startMs)
|
||||
if (detailRes.audioMetadata?.chapters) {
|
||||
detailRes.audioMetadata.chapters.sort((a, b) => a.startMs - b.startMs)
|
||||
}
|
||||
|
||||
if ('mediaSession' in navigator) {
|
||||
@@ -658,8 +658,8 @@ onMounted(async () => {
|
||||
</button>
|
||||
<div class="flex-1 min-w-0 px-1">
|
||||
<p class="text-sm font-semibold truncate">{{ detail.title ?? 'Untitled' }}</p>
|
||||
<p v-if="detail.narrators.length" class="text-xs text-white/55 truncate">
|
||||
{{ detail.narrators.map((n) => n.name).join(', ') }}
|
||||
<p v-if="detail.audioMetadata?.narrators.length" class="text-xs text-white/55 truncate">
|
||||
{{ detail.audioMetadata.narrators.map((n) => n.name).join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Bookmark toggle -->
|
||||
@@ -993,9 +993,9 @@ onMounted(async () => {
|
||||
|
||||
<!-- Chapters list -->
|
||||
<div v-if="chaptersTab === 'chapters'" class="flex-1 overflow-y-auto">
|
||||
<div v-if="detail.chapters?.length">
|
||||
<div v-if="detail.audioMetadata?.chapters?.length">
|
||||
<button
|
||||
v-for="(chapter, i) in detail.chapters"
|
||||
v-for="(chapter, i) in detail.audioMetadata.chapters"
|
||||
:key="chapter.startMs"
|
||||
class="w-full text-left px-5 py-3 hover:bg-white/10 transition-colors text-sm"
|
||||
:class="currentChapter?.startMs === chapter.startMs ? 'text-white font-semibold' : 'text-white/65'"
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, reactive, ref, type PropType } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import BookDetailView from '../BookDetailView.vue'
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
route: null as unknown as { params: { bookId: string }; query: Record<string, unknown> },
|
||||
detail: null as unknown as { value: BookDetail | null },
|
||||
loading: null as unknown as { value: boolean },
|
||||
fetch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => mockState.route,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/book/composables/useBookDetail', () => ({
|
||||
useBookDetail: () => ({
|
||||
detail: mockState.detail,
|
||||
loading: mockState.loading,
|
||||
fetch: mockState.fetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/book/composables/useBookEvents', () => ({
|
||||
useBookEvents: () => ({
|
||||
onBookMissing: () => undefined,
|
||||
onBookRestored: () => undefined,
|
||||
onBookMoved: () => undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/scanner/composables/useScanProgress', () => ({
|
||||
useScanProgress: () => ({ subscribeLibrary: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePageTitle', () => ({
|
||||
usePageTitle: () => undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/composables/usePermissions', () => ({
|
||||
usePermissions: () => ({ hasPermission: () => true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/library/composables/useLibraries', () => ({
|
||||
useLibraries: () => ({ libraries: ref([]) }),
|
||||
}))
|
||||
|
||||
function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
return {
|
||||
id: 217,
|
||||
libraryId: 1,
|
||||
libraryName: 'Test Library',
|
||||
status: 'present',
|
||||
folderPath: '/books',
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
title: 'Rating Test',
|
||||
subtitle: null,
|
||||
description: null,
|
||||
isbn10: null,
|
||||
isbn13: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
pageCount: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
rating: 2,
|
||||
coverSource: null,
|
||||
providerIds: {},
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
files: [],
|
||||
lastWrittenAt: null,
|
||||
metadataScore: null,
|
||||
readStatus: null,
|
||||
audioMetadata: null,
|
||||
formatPriority: [],
|
||||
comicMetadata: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const DetailsTabStub = defineComponent({
|
||||
name: 'DetailsTab',
|
||||
props: {
|
||||
book: {
|
||||
type: Object as PropType<BookDetail>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['saved'],
|
||||
setup(props, { emit }) {
|
||||
function emitSaved() {
|
||||
emit('saved', { ...props.book, rating: 4 })
|
||||
}
|
||||
return { emitSaved }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div data-test="details-rating">{{ book.rating }}</div>
|
||||
<button data-test="details-save" @click="emitSaved">save</button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
const EditMetadataTabStub = defineComponent({
|
||||
name: 'EditMetadataTab',
|
||||
props: {
|
||||
book: {
|
||||
type: Object as PropType<BookDetail>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
template: '<div data-test="edit-rating">{{ book.rating }}</div>',
|
||||
})
|
||||
|
||||
describe('BookDetailView', () => {
|
||||
beforeEach(() => {
|
||||
mockState.route = reactive({
|
||||
params: { bookId: '217' },
|
||||
query: { tab: 'details' },
|
||||
})
|
||||
mockState.detail = ref(makeBook())
|
||||
mockState.loading = ref(false)
|
||||
mockState.fetch.mockReset()
|
||||
})
|
||||
|
||||
it('keeps updated rating when switching details -> edit -> details tabs', async () => {
|
||||
const wrapper = mount(BookDetailView, {
|
||||
global: {
|
||||
stubs: {
|
||||
BookDetailLayout: { template: '<div><slot /></div>' },
|
||||
DetailsTab: DetailsTabStub,
|
||||
EditMetadataTab: EditMetadataTabStub,
|
||||
FilesTab: { template: '<div />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="details-rating"]').text()).toBe('2')
|
||||
|
||||
await wrapper.get('[data-test="details-save"]').trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.get('[data-test="details-rating"]').text()).toBe('4')
|
||||
|
||||
mockState.route.query = { tab: 'edit' }
|
||||
await nextTick()
|
||||
expect(wrapper.get('[data-test="edit-rating"]').text()).toBe('4')
|
||||
|
||||
mockState.route.query = { tab: 'details' }
|
||||
await nextTick()
|
||||
expect(wrapper.get('[data-test="details-rating"]').text()).toBe('4')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user