diff --git a/client/src/components/AppHeader.vue b/client/src/components/AppHeader.vue index de1b98e4a..7bec7bdf5 100644 --- a/client/src/components/AppHeader.vue +++ b/client/src/components/AppHeader.vue @@ -430,7 +430,7 @@ function formatBadgeStyle(fmt: string, result?: GlobalSearchResult) { @@ -559,7 +559,7 @@ function formatBadgeStyle(fmt: string, result?: GlobalSearchResult) { diff --git a/client/src/components/__tests__/AppHeader.spec.ts b/client/src/components/__tests__/AppHeader.spec.ts index c2b8eddb0..ae8ee8905 100644 --- a/client/src/components/__tests__/AppHeader.spec.ts +++ b/client/src/components/__tests__/AppHeader.spec.ts @@ -155,6 +155,7 @@ function makeResult(id: number, title = 'Prey'): GlobalSearchResult { readStatus: null, addedAt: '2026-01-01T00:00:00.000Z', updatedAt: null, + coverVersion: 'legacy:2026-01-01T00:00:00.000Z', metadataScore: null, hasCover: false, hasMetadataLocks: false, diff --git a/client/src/features/book-dock/components/BookDockFileSheet.vue b/client/src/features/book-dock/components/BookDockFileSheet.vue index 655c01290..d9bed296d 100644 --- a/client/src/features/book-dock/components/BookDockFileSheet.vue +++ b/client/src/features/book-dock/components/BookDockFileSheet.vue @@ -4,8 +4,10 @@ import { useI18n } from 'vue-i18n' import { formatDateTime } from '@/i18n/formatters' import { X, BookOpen, Check, Trash2, Sparkles, ArrowLeft, Wand2, AlertCircle } from '@lucide/vue' import { + getBookMediaKind, resolveBookDockSearchTitle, type BookDockFile, + type CoverMedium, type BookDockMetadata, type MetadataCandidate, type MetadataSource, @@ -20,7 +22,7 @@ import MetadataDiffPanel from '@/features/book/components/detail/tabs/MetadataDi import { useBookDockDetail } from '../composables/useBookDockDetail' import { useLibraries } from '@/features/library/composables/useLibraries' import { useMetadataSearch } from '@/features/book/composables/useMetadataSearch' -import type { MetadataPatch } from '@/features/book/composables/useMetadataDiff' +import type { MetadataDiffApply } from '@/features/book/composables/useMetadataDiff' import { formatBytes } from '@/lib/formatting' import { toDisplayCoverUrl } from '@/features/book/lib/metadata-fetch' @@ -277,8 +279,13 @@ function openSearch() { loadProviders() } +// A dock file has one medium, so its search asks only the providers for it, and audio files get square art. +const fileMediaKind = computed(() => getBookMediaKind(props.file.format)) +const fileCoverMedium = computed(() => (fileMediaKind.value === 'audiobook' ? 'audio' : 'ebook')) + function handleSearchSubmit(params: { title: string; author: string; isbn: string }) { - search(params) + const mediaKind = fileMediaKind.value + search(mediaKind === 'unknown' ? params : { ...params, mediaKind }) } function selectCandidate(candidate: MetadataCandidate) { @@ -296,7 +303,7 @@ function backFromDiff() { selectedCandidate.value = null } -async function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) { +async function handleApply(patch: MetadataDiffApply) { const p = patch.formPatch const bookDockPatch = { ...p } delete bookDockPatch.customMetadata @@ -315,9 +322,10 @@ async function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string if ('authors' in p) form.authors = (p.authors ?? []).join(', ') if ('genres' in p) form.genres = (p.genres ?? []).join(', ') - if (patch.coverUrl !== undefined) { - selectedCoverUrl.value = patch.coverUrl - passthroughMetadata.value.coverUrl = patch.coverUrl + const coverUrl = patch.coverUrl ?? patch.audioCoverUrl + if (coverUrl !== undefined) { + selectedCoverUrl.value = coverUrl + passthroughMetadata.value.coverUrl = coverUrl } if (debounceTimer) { @@ -681,6 +689,7 @@ onMounted(() => { :providers="providers" :back-label="diffSource === 'fetched' ? t('common.back') : t('bookDock.sheet.results')" :current-cover-url="currentBookDockCoverUrl" + :cover-medium="fileCoverMedium" :provider-ids="providerIds" @back="backFromDiff" @apply="handleApply" diff --git a/client/src/features/book-dock/components/__tests__/BookDockFileSheet.spec.ts b/client/src/features/book-dock/components/__tests__/BookDockFileSheet.spec.ts index d6fe26899..c4e0a5ef3 100644 --- a/client/src/features/book-dock/components/__tests__/BookDockFileSheet.spec.ts +++ b/client/src/features/book-dock/components/__tests__/BookDockFileSheet.spec.ts @@ -53,11 +53,13 @@ const MetadataSearchPanelStub = defineComponent({ required: true, }, }, + emits: ['search', 'select'], template: '
', }) const MetadataDiffPanelStub = defineComponent({ name: 'MetadataDiffPanel', + props: { coverMedium: { type: String, default: undefined } }, emits: ['apply'], template: '
', }) @@ -249,6 +251,27 @@ describe('BookDockFileSheet metadata search defaults', () => { ) }) + it('searches a docked audio file as an audiobook and stages the audiobook cover it picks', async () => { + const candidate: MetadataCandidate = { provider: 'audible', providerId: 'B0DUNE', title: 'Dune' } + mocks.filteredResults.push(candidate) + const wrapper = mountSheet(makeFile({ fileName: 'Dune.m4b', format: 'm4b' })) + const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Search') + await searchButton!.trigger('click') + + wrapper.getComponent(MetadataSearchPanelStub).vm.$emit('search', { title: 'Dune', author: 'Frank Herbert', isbn: '' }) + expect(mocks.search).toHaveBeenLastCalledWith({ title: 'Dune', author: 'Frank Herbert', isbn: '', mediaKind: 'audiobook' }) + + wrapper.getComponent(MetadataSearchPanelStub).vm.$emit('select', candidate) + await wrapper.vm.$nextTick() + const diff = wrapper.getComponent(MetadataDiffPanelStub) + expect(diff.props('coverMedium')).toBe('audio') + + diff.vm.$emit('apply', { formPatch: {}, audioCoverUrl: 'https://covers.example/dune-audio.jpg' }) + await wrapper.vm.$nextTick() + + expect(mocks.saveMetadata).toHaveBeenCalledWith(1, expect.objectContaining({ coverUrl: 'https://covers.example/dune-audio.jpg' })) + }) + it('requests confirmation before discarding the file', async () => { const dockFile = makeFile() const wrapper = mountSheet(dockFile) diff --git a/client/src/features/book-requests/__tests__/RequestSearchPanel.spec.ts b/client/src/features/book-requests/__tests__/RequestSearchPanel.spec.ts index 86f7f0b4e..e0dd2e9ab 100644 --- a/client/src/features/book-requests/__tests__/RequestSearchPanel.spec.ts +++ b/client/src/features/book-requests/__tests__/RequestSearchPanel.spec.ts @@ -36,6 +36,7 @@ const state = vi.hoisted( routeQuery: Record toastError: ReturnType lastFailure: Ref<{ code: string | null; meta: Record | null; message: string | null } | null> + coverOrder: Ref | null }, ) @@ -72,7 +73,8 @@ vi.mock('@/features/book/composables/useMetadataSearch', async () => { return { useMetadataSearch: () => ({ filteredResults: ref([]), - coverProviderOrder: ref([]), + coverProviderOrder: ref(['amazon', 'itunes']), + audioCoverProviderOrder: ref(['audible', 'itunes']), resultProviderOrder: ref([]), interruptedProviders: ref([]), isStreaming: ref(false), @@ -108,7 +110,12 @@ vi.mock('../composables/useRequestSubmission', async (importOriginal) => { vi.mock('../composables/useCandidateGroups', async () => { const { ref } = await import('vue') state.groups = ref([]) - return { useCandidateGroups: () => ({ groups: state.groups }) } + return { + useCandidateGroups: (_results: unknown, _mediaKind: unknown, _availability: unknown, coverOrder: Ref) => { + state.coverOrder = coverOrder + return { groups: state.groups } + }, + } }) vi.mock('../composables/useRequestDestinationDefault', async (importOriginal) => { @@ -795,4 +802,12 @@ describe('RequestSearchPanel free-text fallback', () => { expect(state.push).not.toHaveBeenCalled() }) + + it('orders an audiobook request’s cover art by the Audiobook cover rule', async () => { + await render() + + expect(state.coverOrder?.value).toEqual(['amazon', 'itunes']) + state.mediaKind.value = 'audiobook' + expect(state.coverOrder?.value).toEqual(['audible', 'itunes']) + }) }) diff --git a/client/src/features/book-requests/components/RequestSearchPanel.vue b/client/src/features/book-requests/components/RequestSearchPanel.vue index ec912de78..a6c6b6068 100644 --- a/client/src/features/book-requests/components/RequestSearchPanel.vue +++ b/client/src/features/book-requests/components/RequestSearchPanel.vue @@ -61,8 +61,18 @@ import RequestCover from './RequestCover.vue' const emit = defineEmits<{ submitted: [] }>() const { t, locale } = useI18n() -const { filteredResults, coverProviderOrder, resultProviderOrder, interruptedProviders, isStreaming, hasSearched, providers, loadProviders, search } = - useMetadataSearch() +const { + filteredResults, + coverProviderOrder, + audioCoverProviderOrder, + resultProviderOrder, + interruptedProviders, + isStreaming, + hasSearched, + providers, + loadProviders, + search, +} = useMetadataSearch() const { libraries, fetchLibraries } = useLibraries() const { hasPermission } = usePermissions() const { user } = useAuth() @@ -106,7 +116,9 @@ const targetFolderId = ref(null) const activeCoverUrls = ref>({}) const failedProviderIcons = ref(new Set()) const expandedMetadataGroups = ref(new Set()) -const { groups } = useCandidateGroups(filteredResults, mediaKind, getAvailability, coverProviderOrder, language, resultProviderOrder) +// An audiobook request shows audiobook art, so its covers follow the Audiobook cover rule. +const requestCoverProviderOrder = computed(() => (mediaKind.value === 'audiobook' ? audioCoverProviderOrder.value : coverProviderOrder.value)) +const { groups } = useCandidateGroups(filteredResults, mediaKind, getAvailability, requestCoverProviderOrder, language, resultProviderOrder) // Nobody approves these requests afterwards, so this is the only chance to say where the book goes. const autoApproves = computed(() => hasPermission(Permission.BookRequestAutoApprove)) diff --git a/client/src/features/book/components/BookCoverCard.test.ts b/client/src/features/book/components/BookCoverCard.test.ts index 57544b822..c8be39860 100644 --- a/client/src/features/book/components/BookCoverCard.test.ts +++ b/client/src/features/book/components/BookCoverCard.test.ts @@ -6,6 +6,9 @@ import BookCoverCard from './BookCoverCard.vue' // -- module mocks (must precede imports) ------------------------------------- +const mockToastError = vi.hoisted(() => vi.fn<(message: string) => void>()) +vi.mock('vue-sonner', () => ({ toast: { error: mockToastError, success: vi.fn<(message: string) => void>() } })) + const mockRouterPush = vi.fn<() => void>() vi.mock('vue-router', () => ({ useRoute: () => ({ fullPath: '/' }), @@ -153,6 +156,7 @@ function makeBook(overrides: Partial = {}): BookCard { readStatus: null, addedAt: '2024-01-01T00:00:00Z', updatedAt: null, + coverVersion: 'legacy:2024-01-01T00:00:00Z', metadataScore: 90, hasCover: false, hasMetadataLocks: false, @@ -467,7 +471,7 @@ describe('BookCoverCard', () => { it('uses coverUrl composable for the image src', () => { const book = makeBook({ id: 42, hasCover: true }) mountCard({ book }) - expect(mockCoverUrl).toHaveBeenCalledWith(42, 'thumbnail', book.addedAt) + expect(mockCoverUrl).toHaveBeenCalledWith(42, 'thumbnail', book.coverVersion) }) it('constrains overlays to the natural fitted cover frame after image load', async () => { @@ -637,6 +641,34 @@ describe('BookCoverCard', () => { expect(mockDownloadFile).toHaveBeenCalledWith(22) expect(mockExportBooks).not.toHaveBeenCalled() }) + + it('reports a failed cover regeneration and keeps the cached cover', async () => { + const fetchMock = vi.fn<() => Promise<{ ok: boolean }>>().mockResolvedValue({ ok: false }) + vi.stubGlobal('fetch', fetchMock) + const wrapper = mountCard({ book: makeBook({ id: 42 }) }) + const regenerate = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().trim() === 'Regenerate Cover') + + await regenerate!.trigger('click') + await flushPromises() + + expect(fetchMock).toHaveBeenCalledWith('/api/v1/books/42/re-extract-cover', { method: 'POST' }) + expect(mockToastError).toHaveBeenCalledWith('Could not regenerate the cover') + expect(mockBumpVersion).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) + + it('refreshes the cover after a successful regeneration', async () => { + vi.stubGlobal('fetch', vi.fn<() => Promise<{ ok: boolean }>>().mockResolvedValue({ ok: true })) + const wrapper = mountCard({ book: makeBook({ id: 42 }) }) + const regenerate = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().trim() === 'Regenerate Cover') + + await regenerate!.trigger('click') + await flushPromises() + + expect(mockBumpVersion).toHaveBeenCalledWith(42) + expect(mockToastError).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) }) // ── cover aspect ratio ──────────────────────────────────────────────────── diff --git a/client/src/features/book/components/BookCoverCard.vue b/client/src/features/book/components/BookCoverCard.vue index b9da119d6..c8d295879 100644 --- a/client/src/features/book/components/BookCoverCard.vue +++ b/client/src/features/book/components/BookCoverCard.vue @@ -53,6 +53,7 @@ import BookCoverArtwork from './BookCoverArtwork.vue' import BookCoverSurface from './BookCoverSurface.vue' import { fetchAuthors } from '@/features/author/api/author' import { useI18n } from 'vue-i18n' +import { toast } from 'vue-sonner' import { hasReadAlong, READ_ALONG_FORMAT_COLOR, READ_ALONG_FORMAT_TITLE } from '@/features/book/lib/file-capabilities' const { t } = useI18n() @@ -104,7 +105,7 @@ const openableFiles = computed(() => { }) const { coverUrl, bumpVersion } = useCoverVersions() -const coverSrc = computed(() => coverUrl(props.book.id, 'thumbnail', props.book.updatedAt ?? props.book.addedAt)) +const coverSrc = computed(() => coverUrl(props.book.id, 'thumbnail', props.book.coverVersion)) const { refreshing, refreshWithFeedback } = useRefreshMetadata() const { isRefreshing } = useRefreshingBooks() @@ -115,8 +116,14 @@ async function reExtractCover() { if (reExtractingCover.value) return reExtractingCover.value = true try { - await fetch(`/api/v1/books/${props.book.id}/re-extract-cover`, { method: 'POST' }) + const res = await fetch(`/api/v1/books/${props.book.id}/re-extract-cover`, { method: 'POST' }) + if (!res.ok) { + toast.error(t('book.coverRegeneration.failed')) + return + } bumpVersion(props.book.id) + } catch { + toast.error(t('book.coverRegeneration.failed')) } finally { reExtractingCover.value = false } @@ -703,7 +710,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe {{ t('book.actions.refreshMetadata') }} - + {{ t('book.actions.regenerateCover') }} @@ -845,7 +852,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe {{ t('book.actions.refreshMetadata') }} - + {{ t('book.actions.regenerateCover') }} diff --git a/client/src/features/book/components/BookCoverLightbox.vue b/client/src/features/book/components/BookCoverLightbox.vue new file mode 100644 index 000000000..fd66fb888 --- /dev/null +++ b/client/src/features/book/components/BookCoverLightbox.vue @@ -0,0 +1,92 @@ + + + diff --git a/client/src/features/book/components/BookListRow.vue b/client/src/features/book/components/BookListRow.vue index 27d285571..8f3d5cc91 100644 --- a/client/src/features/book/components/BookListRow.vue +++ b/client/src/features/book/components/BookListRow.vue @@ -146,7 +146,7 @@ async function setRating(star: number) { } const { coverUrl } = useCoverVersions() -const coverSrc = computed(() => coverUrl(props.book.id, 'thumbnail', props.book.updatedAt ?? props.book.addedAt)) +const coverSrc = computed(() => coverUrl(props.book.id, 'thumbnail', props.book.coverVersion)) const { refreshing, refreshWithFeedback } = useRefreshMetadata() const injectedCoverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO)) @@ -197,7 +197,7 @@ function openSeriesDetails() { } function collapsedCoverVersion(bookId: number): string | null | undefined { - if (bookId === props.book.id) return props.book.updatedAt ?? props.book.addedAt + if (bookId === props.book.id) return props.book.coverVersion return collapsedSeries.value?.coverUpdatedAtByBookId?.[bookId] } diff --git a/client/src/features/book/components/BookQuickView.vue b/client/src/features/book/components/BookQuickView.vue index bc7b19e81..6ca7d5b57 100644 --- a/client/src/features/book/components/BookQuickView.vue +++ b/client/src/features/book/components/BookQuickView.vue @@ -60,7 +60,7 @@ watch( ) const { coverUrl } = useCoverVersions() -const coverSrc = computed(() => (detail.value ? coverUrl(detail.value.id, 'cover', detail.value.updatedAt ?? detail.value.addedAt) : null)) +const coverSrc = computed(() => (detail.value ? coverUrl(detail.value.id, 'cover', detail.value.coverVersion) : null)) const coverSeed = computed(() => (detail.value ? (detail.value.title ?? detail.value.folderPath.split('/').pop() ?? String(detail.value.id)) : '')) const coverPlaceholderTitle = computed(() => (detail.value ? (detail.value.title ?? detail.value.folderPath.split('/').pop() ?? null) : null)) diff --git a/client/src/features/book/components/CollapsedSeriesCard.vue b/client/src/features/book/components/CollapsedSeriesCard.vue index 21131c46a..f93b82589 100644 --- a/client/src/features/book/components/CollapsedSeriesCard.vue +++ b/client/src/features/book/components/CollapsedSeriesCard.vue @@ -107,7 +107,7 @@ function handleSingleCoverError() { } function coverVersionFor(bookId: number): string | null | undefined { - if (bookId === props.book.id) return props.book.updatedAt ?? props.book.addedAt + if (bookId === props.book.id) return props.book.coverVersion return collapsed.value.coverUpdatedAtByBookId?.[bookId] } diff --git a/client/src/features/book/components/CoverFill.vue b/client/src/features/book/components/CoverFill.vue new file mode 100644 index 000000000..4eb4cbf3a --- /dev/null +++ b/client/src/features/book/components/CoverFill.vue @@ -0,0 +1,27 @@ + + + diff --git a/client/src/features/book/components/CoverMediumSwitch.vue b/client/src/features/book/components/CoverMediumSwitch.vue new file mode 100644 index 000000000..b4d8bb24d --- /dev/null +++ b/client/src/features/book/components/CoverMediumSwitch.vue @@ -0,0 +1,61 @@ + + + diff --git a/client/src/features/book/components/VirtualBookGrid.spec.ts b/client/src/features/book/components/VirtualBookGrid.spec.ts index e02ae5c62..7e0551ef5 100644 --- a/client/src/features/book/components/VirtualBookGrid.spec.ts +++ b/client/src/features/book/components/VirtualBookGrid.spec.ts @@ -74,6 +74,7 @@ function makeBook(id: number, overrides: Partial = {}): BookCard { readStatus: null, addedAt: '2026-01-01T00:00:00.000Z', updatedAt: null, + coverVersion: 'legacy:2026-01-01T00:00:00.000Z', metadataScore: null, hasCover: false, hasMetadataLocks: false, diff --git a/client/src/features/book/components/__tests__/BookCoverCard.spec.ts b/client/src/features/book/components/__tests__/BookCoverCard.spec.ts index ef6dc27c8..dcd01cf33 100644 --- a/client/src/features/book/components/__tests__/BookCoverCard.spec.ts +++ b/client/src/features/book/components/__tests__/BookCoverCard.spec.ts @@ -102,6 +102,7 @@ const missingBook: BookCard = { readStatus: null, addedAt: '2026-01-01T00:00:00.000Z', updatedAt: null, + coverVersion: 'legacy:2026-01-01T00:00:00.000Z', metadataScore: null, hasCover: false, hasMetadataLocks: false, @@ -133,6 +134,7 @@ const presentBook: BookCard = { readStatus: null, addedAt: '2026-01-01T00:00:00.000Z', updatedAt: null, + coverVersion: 'legacy:2026-01-01T00:00:00.000Z', metadataScore: null, hasCover: false, hasMetadataLocks: false, diff --git a/client/src/features/book/components/__tests__/BookCoverLightbox.spec.ts b/client/src/features/book/components/__tests__/BookCoverLightbox.spec.ts new file mode 100644 index 000000000..686396967 --- /dev/null +++ b/client/src/features/book/components/__tests__/BookCoverLightbox.spec.ts @@ -0,0 +1,117 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { ref } from 'vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { BookCoverSlot, BookDetail } from '@bookorbit/types' +import { COVER_ASPECT_RATIO_KEY } from '../../lib/cover-aspect-ratio' +import BookCoverLightbox from '../BookCoverLightbox.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})) + +vi.mock('../../composables/useCoverVersions', () => ({ + useCoverVersions: () => ({ + coverUrl: (bookId: number, type: string, version?: string, medium?: string) => + `/api/v1/books/${bookId}/${type}?t=${version}&medium=${medium ?? ''}`, + }), +})) + +type LightboxBook = Pick + +function slot(updatedAt: string): BookCoverSlot { + return { source: 'extracted', updatedAt, width: null, height: null } +} + +const bothSlots: LightboxBook = { id: 3, title: 'Dune', covers: { ebook: slot('e1'), audio: slot('a1') }, coverVersion: 'face' } + +function mountLightbox( + props: { book?: LightboxBook; medium?: 'ebook' | 'audio' | null; previews?: Record } = {}, + aspectRatio = '2/3', +) { + return mount(BookCoverLightbox, { + props: { open: true, book: props.book ?? bothSlots, medium: props.medium, previews: props.previews }, + attachTo: document.body, + global: { provide: { [COVER_ASPECT_RATIO_KEY as symbol]: ref(aspectRatio) } }, + }) +} + +function image(): HTMLImageElement { + return document.body.querySelector('img')! +} + +function radios(): HTMLButtonElement[] { + return [...document.body.querySelectorAll('[role="radio"]')] +} + +describe('BookCoverLightbox', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + it('is a titled dialog with a labelled close button', async () => { + mountLightbox() + await flushPromises() + + const dialog = document.body.querySelector('[role="dialog"]')! + const titleId = dialog.getAttribute('aria-labelledby')! + expect(document.getElementById(titleId)?.textContent).toBe('book.detail.coverLightbox.title') + expect(document.body.querySelector('[aria-label="common.close"]')).not.toBeNull() + }) + + it('opens on the face and switches between the two slots', async () => { + mountLightbox({}, '1/1') + await flushPromises() + + expect(image().getAttribute('src')).toBe('/api/v1/books/3/cover?t=a1&medium=audio') + expect(radios().map((radio) => radio.getAttribute('aria-checked'))).toEqual(['false', 'true']) + + radios()[0]!.click() + await flushPromises() + + expect(image().getAttribute('src')).toBe('/api/v1/books/3/cover?t=e1&medium=ebook') + }) + + it('moves between the slots with the arrow keys', async () => { + mountLightbox() + await flushPromises() + + radios()[0]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })) + await flushPromises() + + expect(radios()[1]!.getAttribute('aria-checked')).toBe('true') + expect(document.activeElement).toBe(radios()[1]) + }) + + it('opens on the slot it is given', async () => { + mountLightbox({ medium: 'audio' }) + await flushPromises() + + expect(image().getAttribute('src')).toBe('/api/v1/books/3/cover?t=a1&medium=audio') + }) + + it('shows no switch and the bare face when only one slot has an image', async () => { + mountLightbox({ book: { ...bothSlots, covers: { ebook: slot('e1'), audio: null } } }) + await flushPromises() + + expect(radios()).toHaveLength(0) + expect(image().getAttribute('src')).toBe('/api/v1/books/3/cover?t=face&medium=') + }) + + it('shows an unsaved editor image in place of its slot', async () => { + mountLightbox({ book: { ...bothSlots, covers: { ebook: slot('e1'), audio: null } }, medium: 'audio', previews: { audio: 'blob:square' } }) + await flushPromises() + + expect(radios()).toHaveLength(2) + expect(image().getAttribute('src')).toBe('blob:square') + }) + + it('asks its parent to close on Escape', async () => { + const wrapper = mountLightbox() + await flushPromises() + + document.body.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await flushPromises() + + expect(wrapper.emitted('update:open')).toEqual([[false]]) + }) +}) diff --git a/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts b/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts index 2247ef892..e7c6a5a2f 100644 --- a/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts +++ b/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts @@ -155,6 +155,22 @@ describe('BookFilterBuilder', () => { expect(wrapper.find('input').exists()).toBe(false) }) + it('offers Audiobook Cover as a missing or present rule with no value input', async () => { + const wrapper = mount(BookFilterBuilder, { + props: { + modelValue: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }] }, + }, + }) + + const [fieldSelect] = wrapper.findAll('select') + expect(fieldSelect!.findAll('option').map((opt) => opt.text())).toEqual(expect.arrayContaining(['Cover', 'Audiobook Cover'])) + await fieldSelect!.setValue('audioCover') + + const operatorSelect = wrapper.findAll('select')[1] + expect(operatorSelect!.findAll('option').map((opt) => opt.text())).toEqual(['is missing', 'is present']) + expect(wrapper.find('input').exists()).toBe(false) + }) + it('includes Series Status in field options', () => { const wrapper = mount(BookFilterBuilder, { props: { diff --git a/client/src/features/book/components/__tests__/BookListRow.spec.ts b/client/src/features/book/components/__tests__/BookListRow.spec.ts index ab3de7fb1..54b68fad2 100644 --- a/client/src/features/book/components/__tests__/BookListRow.spec.ts +++ b/client/src/features/book/components/__tests__/BookListRow.spec.ts @@ -68,6 +68,7 @@ const missingBook: BookCard = { readStatus: null, addedAt: '2026-01-01T00:00:00.000Z', updatedAt: null, + coverVersion: 'legacy:2026-01-01T00:00:00.000Z', metadataScore: null, hasCover: false, hasMetadataLocks: false, @@ -99,6 +100,7 @@ const presentBook: BookCard = { readStatus: null, addedAt: '2026-01-01T00:00:00.000Z', updatedAt: null, + coverVersion: 'legacy:2026-01-01T00:00:00.000Z', metadataScore: null, hasCover: false, hasMetadataLocks: false, diff --git a/client/src/features/book/components/__tests__/BookListRowMoveAction.spec.ts b/client/src/features/book/components/__tests__/BookListRowMoveAction.spec.ts index 4a6ae337e..f1824e9c2 100644 --- a/client/src/features/book/components/__tests__/BookListRowMoveAction.spec.ts +++ b/client/src/features/book/components/__tests__/BookListRowMoveAction.spec.ts @@ -29,6 +29,7 @@ const book: BookCard = { readStatus: null, addedAt: '2026-01-01T00:00:00.000Z', updatedAt: null, + coverVersion: 'legacy:2026-01-01T00:00:00.000Z', metadataScore: null, hasCover: false, hasMetadataLocks: false, diff --git a/client/src/features/book/components/__tests__/BookQuickView.spec.ts b/client/src/features/book/components/__tests__/BookQuickView.spec.ts index bf40f9836..0334cd667 100644 --- a/client/src/features/book/components/__tests__/BookQuickView.spec.ts +++ b/client/src/features/book/components/__tests__/BookQuickView.spec.ts @@ -87,6 +87,9 @@ function makeDetail(overrides: Partial = {}): BookDetail { personalNoteUpdatedAt: null, communityRatings: [], coverSource: null, + coverMedia: [], + covers: { ebook: null, audio: null }, + coverVersion: 'legacy:2026-01-01T00:00:00.000Z', hardcoverEditionId: null, providerIds: {}, authors: [], diff --git a/client/src/features/book/components/__tests__/BookTableCells.spec.ts b/client/src/features/book/components/__tests__/BookTableCells.spec.ts index da2aef602..7f26dc4b2 100644 --- a/client/src/features/book/components/__tests__/BookTableCells.spec.ts +++ b/client/src/features/book/components/__tests__/BookTableCells.spec.ts @@ -244,6 +244,7 @@ describe('BookTableReadButtonCell', () => { readStatus: null, addedAt: '2026-01-01T00:00:00.000Z', updatedAt: null, + coverVersion: 'legacy:2026-01-01T00:00:00.000Z', metadataScore: null, hasCover: false, hasMetadataLocks: false, diff --git a/client/src/features/book/components/__tests__/CollapsedSeriesCard.spec.ts b/client/src/features/book/components/__tests__/CollapsedSeriesCard.spec.ts index 4209fe18e..4a7ada892 100644 --- a/client/src/features/book/components/__tests__/CollapsedSeriesCard.spec.ts +++ b/client/src/features/book/components/__tests__/CollapsedSeriesCard.spec.ts @@ -68,6 +68,7 @@ function makeBook(overrides?: Partial): BookCard { readStatus: null, addedAt: '2024-01-01T00:00:00.000Z', updatedAt: null, + coverVersion: 'legacy:2024-01-01T00:00:00.000Z', metadataScore: null, hasCover: false, hasMetadataLocks: false, diff --git a/client/src/features/book/components/__tests__/CoverFill.spec.ts b/client/src/features/book/components/__tests__/CoverFill.spec.ts new file mode 100644 index 000000000..c75cfdc59 --- /dev/null +++ b/client/src/features/book/components/__tests__/CoverFill.spec.ts @@ -0,0 +1,23 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import CoverFill from '../CoverFill.vue' + +describe('CoverFill', () => { + it('renders a blurred backdrop behind the complete cover', () => { + const wrapper = mount(CoverFill, { + props: { + src: '/api/v1/books/42/cover?t=version', + alt: 'A portrait cover', + loading: 'eager', + }, + }) + + const images = wrapper.findAll('img') + expect(images).toHaveLength(2) + expect(images[0]?.attributes()).toMatchObject({ 'aria-hidden': 'true', alt: '', loading: 'eager' }) + expect(images[0]?.classes()).toEqual(expect.arrayContaining(['object-cover', 'blur-xl', 'scale-125'])) + expect(images[1]?.attributes()).toMatchObject({ alt: 'A portrait cover', loading: 'eager' }) + expect(images[1]?.classes()).toContain('object-contain') + }) +}) diff --git a/client/src/features/book/components/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/__tests__/DetailsTab.spec.ts index 83e1b2d61..54f5e3beb 100644 --- a/client/src/features/book/components/__tests__/DetailsTab.spec.ts +++ b/client/src/features/book/components/__tests__/DetailsTab.spec.ts @@ -69,6 +69,7 @@ const globalStubs = { DialogOverlay: { template: '
' }, DialogContent: { template: '
' }, DialogClose: { template: '' }, + BookCoverLightbox: true, AddToCollectionSheet: true, MoveToLibrarySheet: true, DeleteBookDialog: true, @@ -101,6 +102,9 @@ function makeBook(overrides: Partial = {}): BookDetail { personalNoteUpdatedAt: null, communityRatings: [], coverSource: null, + coverMedia: [], + covers: { ebook: null, audio: null }, + coverVersion: 'legacy:2024-01-01T00:00:00.000Z', hardcoverEditionId: null, providerIds: {}, authors: [], @@ -167,6 +171,29 @@ describe('DetailsTab - missing state', () => { }) describe('DetailsTab - present state', () => { + it('hands the shared cover lightbox the book, opening on the face', async () => { + const book = makeBook({ + coverSource: 'extracted', + coverMedia: ['ebook', 'audio'], + covers: { + ebook: { source: 'extracted', updatedAt: '2026-09-01T00:00:00.000Z', width: 600, height: 900 }, + audio: { source: 'custom', updatedAt: '2026-09-02T00:00:00.000Z', width: 800, height: 800 }, + }, + }) + const wrapper = mount(DetailsTab, { props: { book }, global: globalStubs }) + // Only a cover that has loaded can be opened, so the button appears with it. + expect(wrapper.find('button[aria-label="View larger cover"]').exists()).toBe(false) + wrapper.getComponent({ name: 'BookCoverArtwork' }).vm.$emit('load', 2 / 3) + await wrapper.vm.$nextTick() + + await wrapper.get('button[aria-label="View larger cover"]').trigger('click') + + const lightbox = wrapper.getComponent({ name: 'BookCoverLightbox' }) + expect(lightbox.props('open')).toBe(true) + expect(lightbox.props('book')).toStrictEqual(book) + expect(lightbox.props('medium')).toBeUndefined() + }) + it('does not render the warning banner', () => { const wrapper = mount(DetailsTab, { props: { book: makeBook({ status: 'present' }) }, diff --git a/client/src/features/book/components/__tests__/VirtualBookTable.collapsedSeries.spec.ts b/client/src/features/book/components/__tests__/VirtualBookTable.collapsedSeries.spec.ts index ce55f43a0..fd59cc806 100644 --- a/client/src/features/book/components/__tests__/VirtualBookTable.collapsedSeries.spec.ts +++ b/client/src/features/book/components/__tests__/VirtualBookTable.collapsedSeries.spec.ts @@ -50,6 +50,7 @@ function makeBook(id: number, overrides: Partial = {}): BookCard { readStatus: null, addedAt: '2026-01-01T00:00:00.000Z', updatedAt: null, + coverVersion: 'legacy:2026-01-01T00:00:00.000Z', metadataScore: null, hasCover: false, hasMetadataLocks: false, diff --git a/client/src/features/book/components/detail/files/FilesHeroBar.vue b/client/src/features/book/components/detail/files/FilesHeroBar.vue index 60878fa91..72755c9df 100644 --- a/client/src/features/book/components/detail/files/FilesHeroBar.vue +++ b/client/src/features/book/components/detail/files/FilesHeroBar.vue @@ -28,7 +28,7 @@ const { t } = useI18n() const { coverUrl } = useCoverVersions() const hasCover = computed(() => props.book.coverSource !== null) -const coverSrc = computed(() => coverUrl(props.book.id, 'thumbnail', props.book.updatedAt ?? props.book.addedAt)) +const coverSrc = computed(() => coverUrl(props.book.id, 'thumbnail', props.book.coverVersion)) const isMissing = computed(() => props.book.status === 'missing') /** diff --git a/client/src/features/book/components/detail/tabs/CoverEditorPanel.spec.ts b/client/src/features/book/components/detail/tabs/CoverEditorPanel.spec.ts index 71467b7d9..cb4035b4b 100644 --- a/client/src/features/book/components/detail/tabs/CoverEditorPanel.spec.ts +++ b/client/src/features/book/components/detail/tabs/CoverEditorPanel.spec.ts @@ -1,7 +1,7 @@ import { mount } from '@vue/test-utils' import { ref } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { BookDetail } from '@bookorbit/types' +import type { BookCoverSlot, BookDetail, BookMetadataLockField, CoverMedium } from '@bookorbit/types' import { useCoverEditor } from '../../../composables/useCoverEditor' import CoverEditorPanel from './CoverEditorPanel.vue' @@ -15,7 +15,8 @@ vi.mock('../../../composables/useCoverEditor', () => ({ vi.mock('../../../composables/useCoverVersions', () => ({ useCoverVersions: () => ({ - coverUrl: vi.fn<() => string>().mockReturnValue('/cover.jpg'), + coverUrl: (bookId: number, type: string, version?: string, medium?: string) => + `/api/v1/books/${bookId}/${type}?t=${version}&medium=${medium ?? ''}`, bumpVersion: vi.fn<() => void>(), }), })) @@ -24,127 +25,403 @@ vi.mock('@/features/auth/composables/usePermissions', () => ({ usePermissions: () => ({ hasPermission: vi.fn<() => boolean>().mockReturnValue(true) }), })) -const book = { - id: 1, - title: 'Test Book', - folderPath: '/books/Test Book', - authors: [], - files: [], - coverSource: null, - addedAt: '2026-07-22T00:00:00.000Z', - updatedAt: '2026-07-22T00:00:00.000Z', -} as unknown as BookDetail +function slot(source: BookCoverSlot['source'] = 'extracted', updatedAt = '2026-09-01T00:00:00.000Z'): BookCoverSlot { + return { source, updatedAt, width: null, height: null } +} -function setupEditor() { +function makeBook(overrides: Partial = {}): BookDetail { + return { + id: 1, + title: 'Test Book', + folderPath: '/books/Test Book', + authors: [], + files: [], + coverSource: null, + coverMedia: ['ebook'], + covers: { ebook: null, audio: null }, + coverVersion: 'ebook:-:-', + lockedFields: [], + addedAt: '2026-07-22T00:00:00.000Z', + updatedAt: '2026-07-22T00:00:00.000Z', + ...overrides, + } as unknown as BookDetail +} + +const mixedBook = makeBook({ + coverSource: 'custom', + coverMedia: ['ebook', 'audio'], + covers: { ebook: slot('extracted', '2026-09-01T00:00:00.000Z'), audio: slot('custom', '2026-09-02T00:00:00.000Z') }, +}) + +type FakeEditor = ReturnType + +function fakeEditor() { const uploading = ref(false) + const regenerating = ref(false) + const error = ref(null) + const previewSrc = ref(null) const pendingFile = ref(null) const pendingUrl = ref(null) - const setUrl = vi.fn<(url: string) => void>((url) => { - pendingUrl.value = url || null - }) - const confirm = vi.fn<() => Promise>().mockResolvedValue(true) - vi.mocked(useCoverEditor).mockReturnValue({ + return { uploading, - error: ref(null), - previewSrc: ref(null), + regenerating, + error, + previewSrc, pendingFile, pendingUrl, selectFile: vi.fn<(file: File) => void>(), - setUrl, - clearPending: vi.fn<() => void>(), - confirm, - revert: vi.fn<() => Promise<'extracted' | null | false>>(), - }) - return { uploading, pendingUrl, setUrl, confirm } + setUrl: vi.fn<(url: string) => void>((url) => { + pendingUrl.value = url || null + previewSrc.value = url || null + }), + clearPending: vi.fn<() => void>(() => { + pendingUrl.value = null + pendingFile.value = null + previewSrc.value = null + }), + confirm: vi.fn<() => Promise>(async () => { + pendingUrl.value = null + previewSrc.value = null + return true + }), + revert: vi.fn<() => Promise<'extracted' | null | false>>().mockResolvedValue('extracted'), + regenerate: vi.fn<() => Promise>().mockResolvedValue(true), + } } -function mountPanel(disabled = false) { +let editors: Record + +function mountPanel(options: { book?: BookDetail; disabled?: boolean; lockedFields?: BookMetadataLockField[] } = {}) { return mount(CoverEditorPanel, { - props: { book, disabled }, + props: { book: options.book ?? makeBook(), disabled: options.disabled ?? false, lockedFields: options.lockedFields ?? [] }, + attachTo: document.body, global: { stubs: { - BookCoverPlaceholder: true, - CoverSearchDrawer: true, - Teleport: true, + BookCoverPlaceholder: { template: '
' }, + BookCoverLightbox: true, + CoverSearchDrawer: { props: ['open', 'isAudiobook'], template: '
' }, }, }, }) } +type ExposedPanel = { + confirm: (media?: (CoverMedium | null)[]) => Promise + setUrl: (url: string) => void + hasPending: boolean + pendingMedia: (CoverMedium | null)[] + reset: () => void +} + +function radios(wrapper: ReturnType) { + return wrapper.findAll('[role="radio"]') +} + describe('CoverEditorPanel', () => { beforeEach(() => { vi.useFakeTimers() - setupEditor() + editors = { ebook: fakeEditor(), audio: fakeEditor(), none: fakeEditor() } + vi.mocked(useCoverEditor).mockImplementation((_bookId, medium) => editors[medium ?? 'none'] as never) }) afterEach(() => { vi.useRealTimers() + document.body.innerHTML = '' }) - it('sizes its layout from its own column instead of the viewport', () => { - const wrapper = mountPanel() - const container = wrapper.get('.\\@container\\/cover-editor') - const layout = container.get(':scope > div') + describe('one medium', () => { + it('sizes its layout from its own column instead of the viewport', () => { + const wrapper = mountPanel() + const container = wrapper.get('.\\@container\\/cover-editor') + const layout = container.get(':scope > div') - expect(layout.classes()).toContain('@min-[21rem]/cover-editor:flex-row') - expect(layout.classes()).not.toContain('lg:flex-col') - expect(layout.get(':scope > div').classes()).toContain('@min-[21rem]/cover-editor:w-36') - }) - - it('disables every visible cover mutation control when the parent is disabled', () => { - const { pendingUrl } = setupEditor() - pendingUrl.value = 'https://example.com/cover.jpg' - const wrapper = mountPanel(true) - - const buttons = wrapper.findAll('button') - expect(buttons.length).toBeGreaterThan(0) - expect(buttons.every((button) => button.attributes('disabled') !== undefined)).toBe(true) - expect(wrapper.get('input[type="file"]').attributes('disabled')).toBeDefined() - }) - - it('does not apply a debounced URL after saving disables the panel', async () => { - const { setUrl } = setupEditor() - const wrapper = mountPanel() - const urlModeButton = wrapper.findAll('button').find((button) => button.text().includes('urlTab')) - expect(urlModeButton).toBeDefined() - await urlModeButton!.trigger('click') - await wrapper.get('input').setValue('https://example.com/cover.jpg') - - await wrapper.setProps({ disabled: true }) - await vi.advanceTimersByTimeAsync(400) - - expect(setUrl).not.toHaveBeenCalled() - }) - - it('rejects overlapping exposed confirmations while an upload is active', async () => { - const { uploading, confirm } = setupEditor() - let resolveUpload!: (result: boolean) => void - confirm.mockImplementation(() => { - uploading.value = true - return new Promise((resolve) => { - resolveUpload = (result) => { - uploading.value = false - resolve(result) - } - }) + expect(layout.classes()).toContain('@min-[21rem]/cover-editor:flex-row') + expect(layout.classes()).not.toContain('lg:flex-col') + expect(layout.get(':scope > div').classes()).toContain('@min-[21rem]/cover-editor:w-36') }) - const wrapper = mountPanel() - const exposed = wrapper.vm as unknown as { confirm: () => Promise } - const firstConfirmation = exposed.confirm() - await expect(exposed.confirm()).resolves.toBe(false) - resolveUpload(true) + it('shows a single cover with no tile choice', () => { + const wrapper = mountPanel({ book: makeBook({ coverSource: 'extracted', covers: { ebook: slot(), audio: null } }) }) - await expect(firstConfirmation).resolves.toBe(true) - expect(confirm).toHaveBeenCalledTimes(1) + expect(wrapper.find('[role="radiogroup"]').exists()).toBe(false) + expect(wrapper.get('img').attributes('src')).toContain('medium=ebook') + }) + + it('uses the audio lock key for a book that only has an audiobook', async () => { + const wrapper = mountPanel({ book: makeBook({ coverMedia: ['audio'] }), lockedFields: ['audioCover'] }) + + const lock = wrapper.get('[aria-label="book.detail.coverEditor.unlockCover"]') + await lock.trigger('click') + + expect(wrapper.emitted('toggleLock')).toEqual([['audioCover']]) + expect(wrapper.get('input[type="file"]').attributes('disabled')).toBeDefined() + }) + + it('shows a legacy cover that no slot holds yet', () => { + const wrapper = mountPanel({ book: makeBook({ coverSource: 'custom', coverVersion: 'legacy:1' }) }) + + expect(wrapper.find('[data-test="placeholder"]').exists()).toBe(false) + expect(wrapper.get('img').attributes('src')).toContain('t=legacy:1') + expect(wrapper.text()).toContain('book.detail.coverEditor.revertToOriginal') + }) + + it('disables every visible cover control when the parent is disabled', () => { + editors.ebook.pendingUrl.value = 'https://example.com/cover.jpg' + const wrapper = mountPanel({ disabled: true }) + + const buttons = wrapper.findAll('button') + expect(buttons.length).toBeGreaterThan(0) + expect(buttons.every((button) => button.attributes('disabled') !== undefined)).toBe(true) + expect(wrapper.get('input[type="file"]').attributes('disabled')).toBeDefined() + }) + + it('does not apply a debounced URL after saving disables the panel', async () => { + const wrapper = mountPanel() + await wrapper.get('[aria-pressed="false"]').trigger('click') + await wrapper.get('input[aria-label="book.detail.coverEditor.urlLabel"]').setValue('https://example.com/cover.jpg') + + await wrapper.setProps({ disabled: true }) + await vi.advanceTimersByTimeAsync(400) + + expect(editors.ebook.setUrl).not.toHaveBeenCalled() + }) + + it('rejects overlapping exposed confirmations while an upload is active', async () => { + editors.ebook.pendingUrl.value = 'https://example.com/cover.jpg' + let resolveUpload!: (result: boolean) => void + editors.ebook.confirm.mockImplementation(() => { + editors.ebook.uploading.value = true + return new Promise((resolve) => { + resolveUpload = (result) => { + editors.ebook.uploading.value = false + resolve(result) + } + }) + }) + const wrapper = mountPanel() + const exposed = wrapper.vm as unknown as ExposedPanel + + const firstConfirmation = exposed.confirm() + await expect(exposed.confirm()).resolves.toBe(false) + resolveUpload(true) + + await expect(firstConfirmation).resolves.toBe(true) + expect(editors.ebook.confirm).toHaveBeenCalledTimes(1) + }) + + it('does not invoke an exposed confirmation while disabled', async () => { + editors.ebook.pendingUrl.value = 'https://example.com/cover.jpg' + const wrapper = mountPanel({ disabled: true }) + const exposed = wrapper.vm as unknown as ExposedPanel + + await expect(exposed.confirm()).resolves.toBe(false) + expect(editors.ebook.confirm).not.toHaveBeenCalled() + }) }) - it('does not invoke an exposed confirmation while disabled', async () => { - const { confirm } = setupEditor() - const wrapper = mountPanel(true) - const exposed = wrapper.vm as unknown as { confirm: () => Promise } + describe('two media', () => { + it('offers a Book and an Audiobook tile as a radio group, with the book selected first', () => { + const wrapper = mountPanel({ book: mixedBook }) - await expect(exposed.confirm()).resolves.toBe(false) - expect(confirm).not.toHaveBeenCalled() + const group = wrapper.get('[role="radiogroup"]') + expect(group.attributes('aria-label')).toBe('book.detail.coverEditor.slotsLabel') + expect(radios(wrapper).map((radio) => radio.attributes('aria-label'))).toEqual([ + 'book.detail.coverEditor.slotEbookName', + 'book.detail.coverEditor.slotAudioName', + ]) + expect(radios(wrapper).map((radio) => radio.attributes('aria-checked'))).toEqual(['true', 'false']) + expect(radios(wrapper).map((radio) => radio.attributes('tabindex'))).toEqual(['0', '-1']) + expect(wrapper.text()).toContain('book.detail.coverEditor.editingEbook') + }) + + it('loads each tile from its own slot', () => { + const wrapper = mountPanel({ book: mixedBook }) + + const sources = wrapper.findAll('[role="radio"] img').map((img) => img.attributes('src')) + expect(sources).toEqual([ + '/api/v1/books/1/cover?t=2026-09-01T00:00:00.000Z&medium=ebook', + '/api/v1/books/1/cover?t=2026-09-02T00:00:00.000Z&medium=audio', + ]) + }) + + it('shows the placeholder for an empty slot instead of requesting it', () => { + const wrapper = mountPanel({ + book: makeBook({ coverSource: 'extracted', coverMedia: ['ebook', 'audio'], covers: { ebook: slot(), audio: null } }), + }) + + expect(wrapper.findAll('[role="radio"] img')).toHaveLength(1) + expect(wrapper.findAll('[role="radio"] [data-test="placeholder"]')).toHaveLength(1) + }) + + it('selects a tile on click and points the shared controls at it', async () => { + const wrapper = mountPanel({ book: mixedBook }) + + await radios(wrapper)[1]!.trigger('click') + + expect(radios(wrapper).map((radio) => radio.attributes('aria-checked'))).toEqual(['false', 'true']) + expect(wrapper.text()).toContain('book.detail.coverEditor.editingAudio') + expect(wrapper.get('[data-test="search"]').attributes('data-audiobook')).toBe('true') + const controls = wrapper.get('[role="group"][aria-labelledby]') + expect(wrapper.get(`#${controls.attributes('aria-labelledby')}`).text()).toBe('book.detail.coverEditor.editingAudio') + }) + + it('moves the selection and focus with the arrow keys', async () => { + const wrapper = mountPanel({ book: mixedBook }) + + await radios(wrapper)[0]!.trigger('keydown', { key: 'ArrowRight' }) + await wrapper.vm.$nextTick() + + expect(radios(wrapper)[1]!.attributes('aria-checked')).toBe('true') + expect(document.activeElement).toBe(radios(wrapper)[1]!.element) + }) + + it('keeps each lock outside its radio and toggles that slot', async () => { + const wrapper = mountPanel({ book: mixedBook, lockedFields: ['audioCover'] }) + + expect(wrapper.find('[role="radio"] button').exists()).toBe(false) + await wrapper.get('[aria-label="book.detail.coverEditor.lockEbookCover"]').trigger('click') + await wrapper.get('[aria-label="book.detail.coverEditor.unlockAudioCover"]').trigger('click') + + expect(wrapper.emitted('toggleLock')).toEqual([['cover'], ['audioCover']]) + }) + + it('disables the controls only while the selected tile is locked', async () => { + const wrapper = mountPanel({ book: mixedBook, lockedFields: ['audioCover'] }) + expect(wrapper.get('input[type="file"]').attributes('disabled')).toBeUndefined() + + await radios(wrapper)[1]!.trigger('click') + + expect(wrapper.get('input[type="file"]').attributes('disabled')).toBeDefined() + }) + + it('opens the lightbox on the tile whose View larger was pressed', async () => { + const wrapper = mountPanel({ book: mixedBook }) + + await wrapper.get('[aria-label="book.detail.coverEditor.viewLargerAudio"]').trigger('click') + + const lightbox = wrapper.getComponent({ name: 'BookCoverLightbox' }) + expect(lightbox.props('open')).toBe(true) + expect(lightbox.props('medium')).toBe('audio') + expect(radios(wrapper)[0]!.attributes('aria-checked')).toBe('true') + }) + + it('keeps a pending image per tile and applies URL input to the selected one', async () => { + const wrapper = mountPanel({ book: mixedBook }) + await radios(wrapper)[1]!.trigger('click') + await wrapper.get('[aria-pressed="false"]').trigger('click') + await wrapper.get('input[aria-label="book.detail.coverEditor.urlLabel"]').setValue('https://example.com/square.jpg') + await vi.advanceTimersByTimeAsync(400) + + expect(editors.audio.setUrl).toHaveBeenCalledWith('https://example.com/square.jpg') + expect(editors.ebook.setUrl).not.toHaveBeenCalled() + + await radios(wrapper)[0]!.trigger('click') + expect(wrapper.find('input[aria-label="book.detail.coverEditor.urlLabel"]').exists()).toBe(false) + expect((wrapper.vm as unknown as ExposedPanel).pendingMedia).toEqual(['audio']) + }) + + it('shows Revert only for the selected tile when its slot is custom', async () => { + const wrapper = mountPanel({ book: mixedBook }) + expect(wrapper.text()).not.toContain('book.detail.coverEditor.revertToOriginal') + + await radios(wrapper)[1]!.trigger('click') + const revert = wrapper.findAll('button').find((button) => button.text().includes('book.detail.coverEditor.revertToOriginal')) + await revert!.trigger('click') + await vi.runAllTimersAsync() + + expect(editors.audio.revert).toHaveBeenCalledTimes(1) + expect(wrapper.emitted('coverChanged')).toEqual([['audio']]) + }) + + it('regenerates the selected slot and reports its medium', async () => { + const wrapper = mountPanel({ book: mixedBook }) + await radios(wrapper)[1]!.trigger('click') + + const regenerate = wrapper.findAll('button').find((button) => button.text().includes('book.detail.coverEditor.regenerateCover')) + await regenerate!.trigger('click') + await vi.runAllTimersAsync() + + expect(editors.audio.regenerate).toHaveBeenCalledTimes(1) + expect(editors.ebook.regenerate).not.toHaveBeenCalled() + expect(wrapper.emitted('coverChanged')).toEqual([['audio']]) + }) + + it('saves every pending tile and reports each medium', async () => { + editors.ebook.pendingUrl.value = 'https://example.com/portrait.jpg' + editors.audio.pendingUrl.value = 'https://example.com/square.jpg' + const wrapper = mountPanel({ book: mixedBook }) + const exposed = wrapper.vm as unknown as ExposedPanel + + expect(exposed.hasPending).toBe(true) + await expect(exposed.confirm()).resolves.toBe(true) + + expect(editors.ebook.confirm).toHaveBeenCalledTimes(1) + expect(editors.audio.confirm).toHaveBeenCalledTimes(1) + expect(wrapper.emitted('coverChanged')).toEqual([['ebook'], ['audio']]) + }) + + it('saves only the tiles it is asked for', async () => { + editors.ebook.pendingUrl.value = 'https://example.com/portrait.jpg' + editors.audio.pendingUrl.value = 'https://example.com/square.jpg' + const wrapper = mountPanel({ book: mixedBook }) + + await expect((wrapper.vm as unknown as ExposedPanel).confirm(['audio'])).resolves.toBe(true) + + expect(editors.ebook.confirm).not.toHaveBeenCalled() + expect(editors.audio.confirm).toHaveBeenCalledTimes(1) + }) + + it('stops at the first failed tile and selects it', async () => { + editors.ebook.pendingUrl.value = 'https://example.com/portrait.jpg' + editors.audio.pendingUrl.value = 'https://example.com/square.jpg' + editors.ebook.confirm.mockResolvedValue(false) + const wrapper = mountPanel({ book: mixedBook }) + await radios(wrapper)[1]!.trigger('click') + + await expect((wrapper.vm as unknown as ExposedPanel).confirm()).resolves.toBe(false) + await wrapper.vm.$nextTick() + + expect(editors.audio.confirm).not.toHaveBeenCalled() + expect(radios(wrapper)[0]!.attributes('aria-checked')).toBe('true') + expect(wrapper.emitted('coverChanged')).toBeUndefined() + }) + + it('routes a metadata result cover to the book tile and selects it', async () => { + const wrapper = mountPanel({ book: mixedBook }) + await radios(wrapper)[1]!.trigger('click') + + ;(wrapper.vm as unknown as ExposedPanel).setUrl('https://example.com/result.jpg') + await wrapper.vm.$nextTick() + + expect(editors.ebook.setUrl).toHaveBeenCalledWith('https://example.com/result.jpg') + expect(radios(wrapper)[0]!.attributes('aria-checked')).toBe('true') + }) + + it('clears every tile on reset', async () => { + editors.ebook.pendingUrl.value = 'https://example.com/portrait.jpg' + editors.audio.pendingUrl.value = 'https://example.com/square.jpg' + const wrapper = mountPanel({ book: mixedBook }) + + ;(wrapper.vm as unknown as ExposedPanel).reset() + + expect(editors.ebook.clearPending).toHaveBeenCalled() + expect(editors.audio.clearPending).toHaveBeenCalled() + expect((wrapper.vm as unknown as ExposedPanel).hasPending).toBe(false) + }) + }) + + describe('accessibility of the shared controls', () => { + it('keeps the file input reachable by keyboard and labels the URL input', async () => { + const wrapper = mountPanel() + + const fileInput = wrapper.get('input[type="file"]') + expect(fileInput.classes()).toContain('sr-only') + expect(fileInput.classes()).not.toContain('hidden') + + const modes = wrapper.findAll('[aria-pressed]') + expect(modes.map((mode) => mode.attributes('aria-pressed'))).toEqual(['true', 'false']) + await modes[1]!.trigger('click') + expect(wrapper.find('input[aria-label="book.detail.coverEditor.urlLabel"]').exists()).toBe(true) + }) }) }) diff --git a/client/src/features/book/components/detail/tabs/CoverEditorPanel.vue b/client/src/features/book/components/detail/tabs/CoverEditorPanel.vue index f15acbb00..bc806823f 100644 --- a/client/src/features/book/components/detail/tabs/CoverEditorPanel.vue +++ b/client/src/features/book/components/detail/tabs/CoverEditorPanel.vue @@ -1,85 +1,193 @@