feat(settings): add option to turn book spine off for comics

Adds a display preference to conditionally render book spines on comic
formats. The new setting is defaulted to false. Various book cover
surfaces across the application now respect this preference by checking
if the format is a comic before rendering the spine.

Closes #295
This commit is contained in:
neon
2026-06-15 05:56:39 +00:00
parent f525c01a2f
commit 96e42d2ac1
35 changed files with 548 additions and 23 deletions
@@ -18,6 +18,7 @@ function resetDisplaySettings() {
settings.tableZebraStriping.value = false
settings.tableDensity.value = 'comfortable'
settings.bookSpineOverlay.value = 'off'
settings.showSpineOnComics.value = false
settings.bookShadowStrength.value = 'default'
settings.bookCoverDisplayMode.value = 'blurred-fit'
settings.seriesCardCoverMode.value = 'stack'
@@ -167,6 +168,27 @@ describe('useDisplaySettings preferences helpers', () => {
expect(settings.gridCardSecondaryLabel.value).toBe('author')
})
it('includes showSpineOnComics in snapshot', () => {
settings.showSpineOnComics.value = true
expect(getDisplayPreferencesSnapshot().showSpineOnComics).toBe(true)
})
it('defaults showSpineOnComics to false', () => {
resetDisplaySettings()
expect(settings.showSpineOnComics.value).toBe(false)
})
it('sanitizes boolean showSpineOnComics and drops non-boolean values', () => {
expect(sanitizeDisplayPreferences({ showSpineOnComics: true })).toEqual({ showSpineOnComics: true })
expect(sanitizeDisplayPreferences({ showSpineOnComics: false })).toEqual({ showSpineOnComics: false })
expect(sanitizeDisplayPreferences({ showSpineOnComics: 'yes' })).toEqual({})
})
it('applies showSpineOnComics from preferences', () => {
applyDisplayPreferences({ showSpineOnComics: true })
expect(settings.showSpineOnComics.value).toBe(true)
})
it('includes thumbnailClickAction in snapshot', () => {
settings.thumbnailClickAction.value = 'details'
const snap = getDisplayPreferencesSnapshot()
@@ -18,6 +18,7 @@ function validDisplayPreferences(overrides: Partial<DisplayPreferences> = {}): D
tableZebraStriping: false,
tableDensity: 'comfortable',
bookSpineOverlay: 'subtle',
showSpineOnComics: false,
bookShadowStrength: 'strong',
bookCoverDisplayMode: 'blurred-fit',
cardInfoMode: 'hover-overlay',
@@ -146,6 +146,7 @@ const authorCoverShape = ref<AuthorCoverShape>(normalizeAuthorCoverShape(storage
const tableZebraStriping = ref(storage.get<boolean>('tableZebraStriping', false) === true)
const tableDensity = ref<TableDensity>(normalizeTableDensity(storage.get('tableDensity', 'comfortable')))
const bookSpineOverlay = ref<BookSpineOverlay>(normalizeBookSpineOverlay(storage.get('bookSpineOverlay', DEFAULT_BOOK_SPINE_OVERLAY)))
const showSpineOnComics = ref(storage.get<boolean>('showSpineOnComics', false) === true)
const bookShadowStrength = ref<BookShadowStrength>(normalizeBookShadowStrength(storage.get('bookShadowStrength', DEFAULT_BOOK_SHADOW_STRENGTH)))
const bookCoverDisplayMode = ref<BookCoverDisplayMode>(
normalizeBookCoverDisplayMode(storage.get('bookCoverDisplayMode', DEFAULT_BOOK_COVER_DISPLAY_MODE)),
@@ -172,6 +173,7 @@ watch(authorCoverShape, (v) => storage.set('authorCoverShape', normalizeAuthorCo
watch(tableZebraStriping, (v) => storage.set('tableZebraStriping', v))
watch(tableDensity, (v) => storage.set('tableDensity', normalizeTableDensity(v)))
watch(bookSpineOverlay, (value) => storage.set('bookSpineOverlay', normalizeBookSpineOverlay(value)))
watch(showSpineOnComics, (v) => storage.set('showSpineOnComics', v))
watch(bookShadowStrength, (value) => storage.set('bookShadowStrength', normalizeBookShadowStrength(value)))
watch(bookCoverDisplayMode, (value) => storage.set('bookCoverDisplayMode', normalizeBookCoverDisplayMode(value)))
watch(seriesCardCoverMode, (value) => storage.set('seriesCardCoverMode', normalizeSeriesCardCoverMode(value)))
@@ -196,6 +198,7 @@ export function getDisplayPreferencesSnapshot(): DisplayPreferences {
tableZebraStriping: tableZebraStriping.value === true,
tableDensity: normalizeTableDensity(tableDensity.value),
bookSpineOverlay: normalizeBookSpineOverlay(bookSpineOverlay.value),
showSpineOnComics: showSpineOnComics.value === true,
bookShadowStrength: normalizeBookShadowStrength(bookShadowStrength.value),
bookCoverDisplayMode: normalizeBookCoverDisplayMode(bookCoverDisplayMode.value),
seriesCardCoverMode: normalizeSeriesCardCoverMode(seriesCardCoverMode.value),
@@ -227,6 +230,7 @@ export function sanitizeDisplayPreferences(raw: unknown): Partial<DisplayPrefere
if (typeof obj.tableZebraStriping === 'boolean') out.tableZebraStriping = obj.tableZebraStriping
if (TABLE_DENSITIES.includes(obj.tableDensity as TableDensity)) out.tableDensity = obj.tableDensity as TableDensity
if (BOOK_SPINE_OVERLAYS.includes(obj.bookSpineOverlay as BookSpineOverlay)) out.bookSpineOverlay = obj.bookSpineOverlay as BookSpineOverlay
if (typeof obj.showSpineOnComics === 'boolean') out.showSpineOnComics = obj.showSpineOnComics
if (BOOK_SHADOW_STRENGTHS.includes(obj.bookShadowStrength as BookShadowStrength))
out.bookShadowStrength = obj.bookShadowStrength as BookShadowStrength
if (BOOK_COVER_DISPLAY_MODES.includes(obj.bookCoverDisplayMode as BookCoverDisplayMode)) {
@@ -267,6 +271,7 @@ export function applyDisplayPreferences(raw: unknown): void {
if (prefs.tableZebraStriping !== undefined) tableZebraStriping.value = prefs.tableZebraStriping
if (prefs.tableDensity !== undefined) tableDensity.value = prefs.tableDensity
if (prefs.bookSpineOverlay !== undefined) bookSpineOverlay.value = prefs.bookSpineOverlay
if (prefs.showSpineOnComics !== undefined) showSpineOnComics.value = prefs.showSpineOnComics
if (prefs.bookShadowStrength !== undefined) bookShadowStrength.value = prefs.bookShadowStrength
if (prefs.bookCoverDisplayMode !== undefined) bookCoverDisplayMode.value = prefs.bookCoverDisplayMode
if (prefs.seriesCardCoverMode !== undefined) seriesCardCoverMode.value = prefs.seriesCardCoverMode
@@ -292,6 +297,7 @@ export function useDisplaySettings() {
tableZebraStriping,
tableDensity,
bookSpineOverlay,
showSpineOnComics,
bookShadowStrength,
bookCoverDisplayMode,
seriesCardCoverMode,
@@ -115,6 +115,7 @@ export function initDisplaySettingsSync(): void {
settings.tableZebraStriping.value,
settings.tableDensity.value,
settings.bookSpineOverlay.value,
settings.showSpineOnComics.value,
settings.bookShadowStrength.value,
settings.bookCoverDisplayMode.value,
settings.seriesCardCoverMode.value,
@@ -23,6 +23,7 @@ const props = withDefaults(
imageClass?: string
resetKey?: string | number | null
spine?: boolean
isComic?: boolean
}>(),
{
authorLine: null,
@@ -36,6 +37,7 @@ const props = withDefaults(
imageClass: '',
resetKey: null,
spine: true,
isComic: false,
},
)
@@ -44,7 +46,7 @@ const emit = defineEmits<{
error: []
}>()
const { bookCoverDisplayMode, bookSpineOverlay } = useDisplaySettings()
const { bookCoverDisplayMode, bookSpineOverlay, showSpineOnComics } = useDisplaySettings()
const injectedAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const loaded = ref(false)
@@ -63,7 +65,11 @@ const slotRatio = computed(() => coverAspectRatioValue(String(frameAspectRatio.v
const canRenderImage = computed(() => props.hasCover && props.src !== null && props.src !== '' && !failed.value)
const showBlurredBackdrop = computed(() => effectiveMode.value === 'blurred-fit' && canRenderImage.value && loaded.value)
const useNaturalFrame = computed(() => effectiveMode.value === 'natural-bottom' && canRenderImage.value && loaded.value)
const spineMode = computed(() => (props.spine ? (bookSpineOverlay?.value ?? 'off') : 'off'))
const spineMode = computed(() => {
if (!props.spine) return 'off'
if (props.isComic && !showSpineOnComics.value) return 'off'
return bookSpineOverlay?.value ?? 'off'
})
// In blurred-fit the image is letterboxed inside the full slot, so the spine
// layer must be fitted to the image rectangle. Until the ratio is known that
// fit can't be computed, so hold the layer back rather than let it span the
@@ -75,6 +75,7 @@ const authorQuery = computed(() => props.book.authors[0] ?? null)
const readableFiles = computed(() => props.book.files.filter((f) => f.format && READER_OPENABLE_FORMATS.has(f.format)))
const primaryFile = computed(() => readableFiles.value.find((f) => f.role === 'primary') ?? readableFiles.value[0] ?? null)
const isAudiobook = computed(() => readableFiles.value.some((f) => FORMAT_TO_GROUP[f.format!] === 'audio'))
const isComic = computed(() => readableFiles.value.some((f) => FORMAT_TO_GROUP[f.format!] === 'cbx'))
// For multi-file audiobooks, collapse all tracks into one representative entry.
// The audio reader loads the full track queue from the book, so opening any track is equivalent.
@@ -396,6 +397,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
:class="isMissing || selectionMode ? '' : 'group-hover:scale-[1.02]'"
:interactive="!isMissing && !selectionMode"
:disable-spine="isAudiobook"
:is-comic="isComic"
:style="{ aspectRatio: effectiveCoverAspectRatio }"
>
<!-- Missing border overlay: mirror selected overlay pattern so border is never clipped/hidden -->
@@ -412,6 +414,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
:frame-aspect-ratio="effectiveCoverAspectRatio"
:image-class="isMissing ? 'brightness-50' : ''"
:spine="!isAudiobook"
:is-comic="isComic"
@load="handleArtworkLoad"
@error="handleArtworkError"
/>
@@ -9,6 +9,7 @@ const props = withDefaults(
interactive?: boolean
tag?: string
disableSpine?: boolean
isComic?: boolean
displayMode?: BookCoverDisplayMode
}>(),
{
@@ -16,14 +17,16 @@ const props = withDefaults(
interactive: false,
tag: 'div',
disableSpine: false,
isComic: false,
displayMode: undefined,
},
)
const { bookSpineOverlay, bookShadowStrength, bookCoverDisplayMode } = useDisplaySettings()
const { bookSpineOverlay, bookShadowStrength, bookCoverDisplayMode, showSpineOnComics } = useDisplaySettings()
const spineOverlayMode = computed(() => {
if (props.disableSpine) return 'off'
if (props.isComic && !showSpineOnComics.value) return 'off'
return bookSpineOverlay?.value ?? 'off'
})
@@ -62,6 +62,7 @@ const seriesLine = computed(() => {
const isMissing = computed(() => props.book.status === 'missing')
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
const secondaryFiles = computed(() => props.book.files.filter((f) => f !== primaryFile.value))
const uniqueSecondaryFiles = computed(() => {
@@ -175,6 +176,7 @@ function handleRowClick(event: MouseEvent) {
size="mini"
class="book-cover-surface--spine-fitted w-16 rounded shrink-0 overflow-hidden relative"
:disable-spine="isAudiobook"
:is-comic="isComic"
:class="isMissing ? 'opacity-50 grayscale' : ''"
:style="{ aspectRatio: coverAspectRatio }"
>
@@ -188,6 +190,7 @@ function handleRowClick(event: MouseEvent) {
:alt="book.title ?? ''"
backdrop-class="blur-md brightness-50"
:spine="!isAudiobook"
:is-comic="isComic"
/>
</BookCoverSurface>
@@ -177,6 +177,7 @@ const quickViewCoverAspectRatio = computed(() => {
const primaryFile = computed(() => detail.value?.files.find((f) => f.role === 'primary') ?? detail.value?.files[0] ?? null)
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
const knownFormats = computed(() => [
...new Set((detail.value?.files ?? []).filter((f) => f.format && FORMAT_TO_GROUP[f.format]).map((f) => f.format!)),
])
@@ -283,6 +284,7 @@ function handleDelete() {
size="mini"
class="book-cover-surface--spine-fitted w-24 shrink-0 rounded overflow-hidden relative"
:disable-spine="isPrimaryAudio"
:is-comic="isPrimaryComic"
:class="detail.coverSource && !coverFailed ? 'cursor-zoom-in' : ''"
:style="{ aspectRatio: quickViewCoverAspectRatio }"
@click="handleCoverClick"
@@ -298,6 +300,7 @@ function handleDelete() {
:frame-aspect-ratio="quickViewCoverAspectRatio"
loading="eager"
:spine="!isPrimaryAudio"
:is-comic="isPrimaryComic"
@load="handleCoverLoad"
@error="handleCoverError"
/>
@@ -83,6 +83,7 @@ watch(
)
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
function handleCoverLoad(bookId: number) {
loadedCovers.value = new Set([...loadedCovers.value, bookId])
@@ -194,6 +195,7 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
:class="isStack ? 'book-cover-surface--spine-fitted' : ''"
interactive
:disable-spine="isAudiobook"
:is-comic="isComic"
:style="{ aspectRatio: coverAspectRatio, ...(isStack ? { backgroundColor: 'transparent', boxShadow: 'none' } : {}) }"
>
<div v-if="isStack" class="absolute inset-0 isolate overflow-hidden" data-testid="series-cover-stack">
@@ -215,6 +217,7 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
loading="lazy"
decoding="async"
:spine="!isAudiobook"
:is-comic="isComic"
@error="() => handleCoverError(bookId)"
/>
@@ -7,7 +7,7 @@ import { clearCoverLoadCache } from '@/features/book/lib/cover-load-cache'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import BookCoverArtwork from '../BookCoverArtwork.vue'
const { bookCoverDisplayMode, bookSpineOverlay } = useDisplaySettings()
const { bookCoverDisplayMode, bookSpineOverlay, showSpineOnComics } = useDisplaySettings()
function mountArtwork(
options: {
@@ -16,6 +16,7 @@ function mountArtwork(
src?: string | null
frameAspectRatio?: string
spine?: boolean
isComic?: boolean
} = {},
) {
return mount(BookCoverArtwork, {
@@ -30,6 +31,7 @@ function mountArtwork(
mode: options.mode,
frameAspectRatio: options.frameAspectRatio,
spine: options.spine,
isComic: options.isComic,
},
global: {
provide: {
@@ -59,11 +61,13 @@ async function triggerMainImageLoad(wrapper: ReturnType<typeof mountArtwork>, na
beforeEach(() => {
clearCoverLoadCache()
bookSpineOverlay.value = 'off'
showSpineOnComics.value = false
})
afterEach(() => {
bookCoverDisplayMode.value = 'blurred-fit'
bookSpineOverlay.value = 'off'
showSpineOnComics.value = false
})
describe('BookCoverArtwork', () => {
@@ -172,6 +176,26 @@ describe('BookCoverArtwork', () => {
expect(wrapper.find('.book-cover-spine-layer').exists()).toBe(false)
})
it('suppresses the spine layer for comics when showSpineOnComics is disabled', async () => {
bookSpineOverlay.value = 'strong'
showSpineOnComics.value = false
const wrapper = mountArtwork({ mode: 'fill-crop', isComic: true })
await triggerMainImageLoad(wrapper, 600, 900)
expect(wrapper.find('.book-cover-spine-layer').exists()).toBe(false)
})
it('renders the spine layer for comics when showSpineOnComics is enabled', async () => {
bookSpineOverlay.value = 'strong'
showSpineOnComics.value = true
const wrapper = mountArtwork({ mode: 'fill-crop', isComic: true })
await triggerMainImageLoad(wrapper, 600, 900)
expect(wrapper.find('.book-cover-spine-layer').attributes('data-cover-spine')).toBe('strong')
})
it('tags the spine layer with the active overlay mode so it works without a surface ancestor', async () => {
bookSpineOverlay.value = 'subtle'
const wrapper = mountArtwork({ mode: 'blurred-fit' })
@@ -3,12 +3,13 @@ import { afterEach, describe, expect, it } from 'vitest'
import BookCoverSurface from '../BookCoverSurface.vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
const { bookSpineOverlay, bookShadowStrength, bookCoverDisplayMode } = useDisplaySettings()
const { bookSpineOverlay, bookShadowStrength, bookCoverDisplayMode, showSpineOnComics } = useDisplaySettings()
afterEach(() => {
bookSpineOverlay.value = 'off'
bookShadowStrength.value = 'default'
bookCoverDisplayMode.value = 'blurred-fit'
showSpineOnComics.value = false
})
describe('BookCoverSurface', () => {
@@ -72,4 +73,34 @@ describe('BookCoverSurface', () => {
expect(wrapper.attributes('data-cover-spine')).toBe('off')
})
it('forces spine overlay off for comics when showSpineOnComics is disabled', () => {
bookSpineOverlay.value = 'strong'
showSpineOnComics.value = false
const wrapper = mount(BookCoverSurface, {
props: { isComic: true },
})
expect(wrapper.attributes('data-cover-spine')).toBe('off')
})
it('keeps the spine overlay for comics when showSpineOnComics is enabled', () => {
bookSpineOverlay.value = 'strong'
showSpineOnComics.value = true
const wrapper = mount(BookCoverSurface, {
props: { isComic: true },
})
expect(wrapper.attributes('data-cover-spine')).toBe('strong')
})
it('does not affect non-comic covers when showSpineOnComics is disabled', () => {
bookSpineOverlay.value = 'subtle'
showSpineOnComics.value = false
const wrapper = mount(BookCoverSurface, {
props: { isComic: false },
})
expect(wrapper.attributes('data-cover-spine')).toBe('subtle')
})
})
@@ -15,6 +15,7 @@ export interface CarouselBook {
hasCover: boolean
authors: string[]
isAudiobook?: boolean
isComic?: boolean
}
const props = withDefaults(
@@ -55,6 +56,10 @@ function isAudiobook(book: CarouselBook): boolean {
return book.isAudiobook ?? false
}
function isComic(book: CarouselBook): boolean {
return book.isComic ?? false
}
function cardAspectRatio(book: CarouselBook): string {
return isAudiobook(book) ? '1/1' : DEFAULT_COVER_ASPECT_RATIO
}
@@ -121,6 +126,7 @@ defineExpose({ scroll })
class="book-cover-surface--spine-fitted relative w-full rounded-sm overflow-hidden transition-transform duration-150 group-hover:scale-[1.02]"
:interactive="true"
:disable-spine="isAudiobook(book)"
:is-comic="isComic(book)"
:display-mode="isAudiobook(book) ? 'fill-crop' : undefined"
:style="{ aspectRatio: cardAspectRatio(book) }"
>
@@ -136,6 +142,7 @@ defineExpose({ scroll })
:mode="isAudiobook(book) ? 'fill-crop' : undefined"
:reset-key="`${coverResetVersion}:${book.id}`"
:spine="!isAudiobook(book)"
:is-comic="isComic(book)"
/>
<span
v-if="showSeriesIndex && book.seriesIndex != null"
@@ -286,6 +286,7 @@ const detailCoverAspectRatio = computed(() => {
})
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
const readableFiles = computed(() => props.book.files.filter((f) => f.format && READER_OPENABLE_FORMATS.has(f.format)))
// For multi-file audiobooks, collapse all tracks into one representative entry.
@@ -961,6 +962,7 @@ watch(
<BookCoverSurface
class="book-cover-surface--spine-fitted relative w-full rounded-sm overflow-hidden"
:disable-spine="isPrimaryAudio"
:is-comic="isPrimaryComic"
:class="hasCover && coverLoaded && !coverFailed ? 'cursor-zoom-in' : ''"
:style="{ aspectRatio: detailCoverAspectRatio }"
@click="handleCoverClick"
@@ -977,6 +979,7 @@ watch(
loading="eager"
backdrop-class="blur-lg brightness-50"
:spine="!isPrimaryAudio"
:is-comic="isPrimaryComic"
@load="handleCoverLoad"
@error="handleCoverError"
/>
@@ -1191,6 +1194,7 @@ watch(
<BookCoverSurface
class="book-cover-surface--spine-fitted group relative w-full rounded-sm overflow-hidden"
:disable-spine="isPrimaryAudio"
:is-comic="isPrimaryComic"
:class="hasCover && coverLoaded && !coverFailed ? 'cursor-zoom-in' : ''"
:style="{ aspectRatio: detailCoverAspectRatio }"
@click="handleCoverClick"
@@ -1218,6 +1222,7 @@ watch(
loading="eager"
backdrop-class="blur-lg brightness-50"
:spine="!isPrimaryAudio"
:is-comic="isPrimaryComic"
@load="handleCoverLoad"
@error="handleCoverError"
/>
@@ -74,6 +74,7 @@ const lockStateClass = computed(() => {
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
</script>
<template>
@@ -95,6 +96,7 @@ const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_T
:title="book.title"
:has-cover="book.hasCover"
:is-audio="isAudiobook"
:is-comic="isComic"
@cover-click="emit('coverClick')"
/>
@@ -17,6 +17,7 @@ const bookCount = computed(() => collapsed.value.bookCount)
const coverIds = computed(() => collapsed.value.coverBookIds.filter((id) => id > 0).slice(0, 4))
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
function thumbnailUrl(bookId: number): string {
return `/api/v1/books/${bookId}/thumbnail`
@@ -48,6 +49,7 @@ function thumbnailUrl(bookId: number): string {
:key="coverId"
size="mini"
:disable-spine="isAudiobook"
:is-comic="isComic"
class="h-8 w-6 rounded-sm overflow-hidden"
>
<img :src="thumbnailUrl(coverId)" class="h-full w-full object-cover" loading="lazy" alt="" />
@@ -56,6 +58,7 @@ function thumbnailUrl(bookId: number): string {
v-if="coverIds.length === 0"
size="mini"
:disable-spine="isAudiobook"
:is-comic="isComic"
class="h-8 w-6 rounded-sm bg-muted flex items-center justify-center"
>
<span class="text-[8px] text-muted-foreground">?</span>
@@ -12,6 +12,7 @@ const props = defineProps<{
title: string | null
hasCover: boolean
isAudio: boolean
isComic: boolean
}>()
const emit = defineEmits<{ 'cover-click': [] }>()
@@ -76,6 +77,7 @@ const adjustedTop = computed(() => {
<BookCoverSurface
size="mini"
:disable-spine="isAudio"
:is-comic="isComic"
tabindex="0"
role="button"
aria-label="View cover"
@@ -93,6 +95,7 @@ const adjustedTop = computed(() => {
:alt="title ?? ''"
frame-aspect-ratio="1/1"
:spine="!isAudio"
:is-comic="isComic"
/>
<div v-if="isRefreshingBook" class="absolute inset-0 flex items-center justify-center bg-black/50">
<Loader2 :size="12" class="animate-spin text-white" />
@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { BookCard } from '@bookorbit/types'
import type { CellType, ColumnId } from '@/features/book/composables/tableColumnSchema'
import BookTableCellDispatcher from '../BookTableCellDispatcher.vue'
function makeBook(overrides: Partial<BookCard> = {}): BookCard {
@@ -36,8 +37,8 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
const baseProps = {
book: makeBook(),
colId: 'lockRow' as const,
cellType: 'lockRow' as const,
colId: 'lockRow' as ColumnId,
cellType: 'lockRow' as CellType,
hasLockField: false,
isLocked: false,
isActive: false,
@@ -49,6 +50,12 @@ const baseProps = {
value: null,
}
const CoverCellStub = {
name: 'BookTableCoverCell',
props: ['isComic', 'isAudio'],
template: '<div data-testid="cover-cell" :data-comic="isComic" />',
}
function mountDispatcher(overrides: Partial<typeof baseProps> = {}) {
return mount(BookTableCellDispatcher, {
props: { ...baseProps, ...overrides },
@@ -56,6 +63,7 @@ function mountDispatcher(overrides: Partial<typeof baseProps> = {}) {
stubs: {
Lock: { template: '<svg data-test="lock-icon" />' },
LockOpen: { template: '<svg data-test="lock-open-icon" />' },
BookTableCoverCell: CoverCellStub,
},
},
})
@@ -86,6 +94,20 @@ describe('BookTableCellDispatcher lock row', () => {
expect(wrapper.emitted('unlockAll')).toBeFalsy()
})
it('passes the comic flag to the cover cell for comic primary files', () => {
const book = makeBook({ files: [{ id: 1, format: 'cbz', role: 'primary', sizeBytes: null }] })
const wrapper = mountDispatcher({ book, colId: 'cover' as const, cellType: 'cover' as const })
expect(wrapper.find('[data-testid="cover-cell"]').attributes('data-comic')).toBe('true')
})
it('does not flag non-comic primary files as comics in the cover cell', () => {
const book = makeBook({ files: [{ id: 1, format: 'epub', role: 'primary', sizeBytes: null }] })
const wrapper = mountDispatcher({ book, colId: 'cover' as const, cellType: 'cover' as const })
expect(wrapper.find('[data-testid="cover-cell"]').attributes('data-comic')).toBe('false')
})
it('renders fully locked state and emits unlock-all', async () => {
const wrapper = mountDispatcher({ isFullyLocked: true, lockedFieldCount: 12 })
const button = wrapper.get('button')
@@ -0,0 +1,65 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { BookCard } from '@bookorbit/types'
import BookTableCollapsedSeriesCell from '../BookTableCollapsedSeriesCell.vue'
function makeBook(format: string | null): BookCard {
return {
id: 1,
status: 'present',
title: 'Saga',
authors: [],
seriesName: 'Saga',
seriesIndex: null,
files: format ? [{ id: 1, format, role: 'primary', sizeBytes: null }] : [],
publishedYear: null,
language: null,
genres: [],
tags: [],
rating: null,
readingProgress: null,
readStatus: null,
addedAt: '2026-01-01T00:00:00.000Z',
updatedAt: null,
metadataScore: null,
hasCover: false,
hasMetadataLocks: false,
lockedFields: [],
subtitle: null,
publisher: null,
pageCount: null,
isbn13: null,
narrators: [],
collapsedSeries: { bookCount: 3, readCount: 1, coverBookIds: [10, 11], seriesLatestAddedAt: null },
} as BookCard
}
const CoverSurfaceStub = {
name: 'BookCoverSurface',
props: ['isComic', 'disableSpine'],
template: '<div data-testid="surface" :data-comic="isComic"><slot /></div>',
}
function mountCell(format: string | null) {
return mount(BookTableCollapsedSeriesCell, {
props: { book: makeBook(format), colId: 'cover' as never },
global: { stubs: { BookCoverSurface: CoverSurfaceStub } },
})
}
describe('BookTableCollapsedSeriesCell comic flag', () => {
it('flags comic series covers via isComic', () => {
const wrapper = mountCell('cbz')
const surfaces = wrapper.findAll('[data-testid="surface"]')
expect(surfaces.length).toBeGreaterThan(0)
expect(surfaces.every((s) => s.attributes('data-comic') === 'true')).toBe(true)
})
it('does not flag non-comic series covers', () => {
const wrapper = mountCell('epub')
const surfaces = wrapper.findAll('[data-testid="surface"]')
expect(surfaces.every((s) => s.attributes('data-comic') === 'false')).toBe(true)
})
})
@@ -0,0 +1,45 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import BookTableCoverCell from '../BookTableCoverCell.vue'
vi.mock('../../../composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg' }),
}))
vi.mock('@/features/book/composables/useRefreshingBooks', () => ({
useRefreshingBooks: () => ({ isRefreshing: () => false }),
}))
const CoverSurfaceStub = {
name: 'BookCoverSurface',
props: ['isComic', 'disableSpine'],
template: '<div data-testid="surface" :data-comic="isComic" :data-audio="disableSpine"><slot /></div>',
}
const CoverArtworkStub = {
name: 'BookCoverArtwork',
props: ['isComic', 'spine'],
template: '<div data-testid="artwork" :data-comic="isComic" :data-spine="spine" />',
}
function mountCell(props: { isAudio?: boolean; isComic?: boolean } = {}) {
return mount(BookTableCoverCell, {
props: { bookId: 1, title: 'Saga', hasCover: true, isAudio: props.isAudio ?? false, isComic: props.isComic ?? false },
global: {
stubs: { BookCoverSurface: CoverSurfaceStub, BookCoverArtwork: CoverArtworkStub, BookCoverImage: true, Teleport: true },
},
})
}
describe('BookTableCoverCell comic flag', () => {
it('forwards isComic to the cover surface and artwork', () => {
const wrapper = mountCell({ isComic: true })
expect(wrapper.find('[data-testid="surface"]').attributes('data-comic')).toBe('true')
expect(wrapper.find('[data-testid="artwork"]').attributes('data-comic')).toBe('true')
})
it('does not flag non-comic covers', () => {
const wrapper = mountCell({ isComic: false })
expect(wrapper.find('[data-testid="surface"]').attributes('data-comic')).toBe('false')
})
})
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { BookOpen, Play } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { FORMAT_TO_GROUP } from '@bookorbit/types'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
@@ -15,6 +16,10 @@ function goToBook(bookId: number) {
void router.push({ name: 'book-detail', params: { bookId } })
}
function isComic(fileFormat: string | null): boolean {
return fileFormat != null && FORMAT_TO_GROUP[fileFormat] === 'cbx'
}
function continueReading(bookId: number, fileId: number | null, fileFormat: string | null) {
if (fileId) {
void router.push({ name: 'reader', params: { bookId, fileId }, query: { format: fileFormat ?? 'epub' } })
@@ -64,7 +69,11 @@ function continueReading(bookId: number, fileId: number | null, fileFormat: stri
@click="goToBook(book.bookId)"
>
<!-- Cover thumbnail -->
<BookCoverSurface size="mini" class="book-cover-surface--spine-fitted h-14 w-9 shrink-0 overflow-hidden rounded">
<BookCoverSurface
size="mini"
class="book-cover-surface--spine-fitted h-14 w-9 shrink-0 overflow-hidden rounded"
:is-comic="isComic(book.fileFormat)"
>
<BookCoverArtwork
:src="coverUrl(book.bookId)"
:has-cover="book.hasCover"
@@ -74,6 +83,7 @@ function continueReading(bookId: number, fileId: number | null, fileFormat: stri
:seed="book.title ?? String(book.bookId)"
:alt="book.title ?? 'Book cover'"
frame-aspect-ratio="9/14"
:is-comic="isComic(book.fileFormat)"
/>
</BookCoverSurface>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Clock, Play } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { FORMAT_TO_GROUP } from '@bookorbit/types'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
@@ -11,6 +13,11 @@ const { data, loading, error } = useLongWaitWidget()
const router = useRouter()
const { coverUrl } = useCoverVersions()
const isComic = computed(() => {
const format = data.value?.fileFormat
return format != null && FORMAT_TO_GROUP[format] === 'cbx'
})
function goToBook() {
if (!data.value) return
void router.push({ name: 'book-detail', params: { bookId: data.value.bookId } })
@@ -62,6 +69,7 @@ function startReading() {
type="button"
size="mini"
class="book-cover-surface--spine-fitted h-24 w-18 shrink-0 cursor-pointer overflow-hidden rounded transition-opacity hover:opacity-80"
:is-comic="isComic"
@click="goToBook"
>
<BookCoverArtwork
@@ -73,6 +81,7 @@ function startReading() {
:seed="data.title ?? String(data.bookId)"
:alt="data.title ?? 'Cover'"
frame-aspect-ratio="3/4"
:is-comic="isComic"
/>
</BookCoverSurface>
@@ -0,0 +1,60 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { CurrentlyReadingWidgetData } from '@bookorbit/types'
import CurrentlyReadingWidget from '../CurrentlyReadingWidget.vue'
const fetchCurrentlyReading = vi.fn<() => Promise<CurrentlyReadingWidgetData>>()
vi.mock('@/features/dashboard/api/dashboard-widget.api', () => ({
fetchCurrentlyReading: () => fetchCurrentlyReading(),
}))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn<(...args: unknown[]) => void>() }) }))
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg' }),
}))
const CoverSurfaceStub = {
name: 'BookCoverSurface',
props: ['isComic'],
template: '<div data-testid="surface" :data-comic="isComic"><slot /></div>',
}
const CoverArtworkStub = {
name: 'BookCoverArtwork',
props: ['isComic'],
template: '<div data-testid="artwork" :data-comic="isComic" />',
}
function book(fileFormat: string | null) {
return { bookId: 1, title: 'Saga', authors: ['Brian K. Vaughan'], progress: 40, hasCover: true, fileId: 9, fileFormat }
}
function mountWidget(fileFormat: string | null) {
fetchCurrentlyReading.mockResolvedValue({ books: [book(fileFormat)] })
return mount(CurrentlyReadingWidget, {
global: { stubs: { BookCoverSurface: CoverSurfaceStub, BookCoverArtwork: CoverArtworkStub } },
})
}
describe('CurrentlyReadingWidget comic spine flag', () => {
it('marks comic books as comics for the cover surface and artwork', async () => {
const wrapper = mountWidget('cbz')
await flushPromises()
expect(wrapper.find('[data-testid="surface"]').attributes('data-comic')).toBe('true')
expect(wrapper.find('[data-testid="artwork"]').attributes('data-comic')).toBe('true')
})
it('does not mark non-comic books as comics', async () => {
const wrapper = mountWidget('epub')
await flushPromises()
expect(wrapper.find('[data-testid="surface"]').attributes('data-comic')).toBe('false')
})
it('treats a null file format as non-comic', async () => {
const wrapper = mountWidget(null)
await flushPromises()
expect(wrapper.find('[data-testid="surface"]').attributes('data-comic')).toBe('false')
})
})
@@ -0,0 +1,63 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { LongWaitWidgetData } from '@bookorbit/types'
import LongWaitWidget from '../LongWaitWidget.vue'
const fetchLongWait = vi.fn<() => Promise<LongWaitWidgetData | null>>()
vi.mock('@/features/dashboard/api/dashboard-widget.api', () => ({
fetchLongWait: () => fetchLongWait(),
}))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn<(...args: unknown[]) => void>() }) }))
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg' }),
}))
const CoverSurfaceStub = {
name: 'BookCoverSurface',
props: ['isComic'],
template: '<div data-testid="surface" :data-comic="isComic"><slot /></div>',
}
const CoverArtworkStub = {
name: 'BookCoverArtwork',
props: ['isComic'],
template: '<div data-testid="artwork" :data-comic="isComic" />',
}
function data(fileFormat: string | null): LongWaitWidgetData {
return {
bookId: 1,
title: 'Bone',
hasCover: true,
addedAt: '2024-01-01',
waitingDays: 120,
pageCount: 200,
genre: 'comic',
fileId: 9,
fileFormat,
}
}
function mountWidget(fileFormat: string | null) {
fetchLongWait.mockResolvedValue(data(fileFormat))
return mount(LongWaitWidget, {
global: { stubs: { BookCoverSurface: CoverSurfaceStub, BookCoverArtwork: CoverArtworkStub } },
})
}
describe('LongWaitWidget comic spine flag', () => {
it('marks comic books as comics for the cover surface and artwork', async () => {
const wrapper = mountWidget('cb7')
await flushPromises()
expect(wrapper.find('[data-testid="surface"]').attributes('data-comic')).toBe('true')
expect(wrapper.find('[data-testid="artwork"]').attributes('data-comic')).toBe('true')
})
it('does not mark non-comic books as comics', async () => {
const wrapper = mountWidget('pdf')
await flushPromises()
expect(wrapper.find('[data-testid="surface"]').attributes('data-comic')).toBe('false')
})
})
@@ -9,7 +9,7 @@ import {
type CardOverlayKey,
} from '@/composables/useDisplaySettings'
const { cardOverlays, bookSpineOverlay, bookShadowStrength, bookCoverDisplayMode } = useDisplaySettings()
const { cardOverlays, bookSpineOverlay, showSpineOnComics, bookShadowStrength, bookCoverDisplayMode } = useDisplaySettings()
const OVERLAY_OPTIONS: { key: CardOverlayKey; label: string; hint: string }[] = [
{ key: 'progress-bar', label: 'Progress bar', hint: 'Thin colored line along the bottom edge' },
@@ -107,6 +107,15 @@ function setBookCoverDisplayMode(mode: BookCoverDisplayMode) {
</button>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="min-w-0">
<p class="settings-label">Show spine on comics</p>
<p class="settings-hint overflow-hidden text-ellipsis whitespace-nowrap md:whitespace-normal md:overflow-visible">
Apply the spine and gloss effect to comic covers (cbz, cbr, cb7)
</p>
</div>
<ToggleSwitch v-model="showSpineOnComics" />
</div>
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Cover shadow strength</p>
@@ -0,0 +1,40 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import AppearanceBookCoverSettings from '../AppearanceBookCoverSettings.vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
const { showSpineOnComics } = useDisplaySettings()
const ToggleSwitchStub = {
name: 'ToggleSwitch',
props: ['modelValue'],
emits: ['update:modelValue'],
template: '<button data-testid="toggle" :data-on="modelValue" @click="$emit(\'update:modelValue\', !modelValue)" />',
}
function mountSettings() {
return mount(AppearanceBookCoverSettings, {
global: {
stubs: { ToggleSwitch: ToggleSwitchStub },
},
})
}
afterEach(() => {
showSpineOnComics.value = false
})
describe('AppearanceBookCoverSettings', () => {
it('reflects and toggles showSpineOnComics from the dedicated switch', async () => {
const wrapper = mountSettings()
const row = wrapper.findAll('.flex.items-center.justify-between').find((r) => r.text().includes('Show spine on comics'))
expect(row).toBeTruthy()
const toggle = row!.find('[data-testid="toggle"]')
expect(toggle.attributes('data-on')).toBe('false')
await toggle.trigger('click')
expect(showSpineOnComics.value).toBe(true)
})
})
+7
View File
@@ -12,6 +12,11 @@ export function isAudioFormat(format: string): boolean {
return AUDIO_FORMATS.has(format.toLowerCase());
}
const COMIC_FORMATS = new Set<string>(["cbz", "cbr", "cb7", "cbx"]);
export function isComicFormat(format: string): boolean {
return COMIC_FORMATS.has(format.toLowerCase());
}
export const READ_STATUSES = ["unread", "want_to_read", "reading", "on_hold", "rereading", "read", "skimmed", "abandoned"] as const;
export type ReadStatus = (typeof READ_STATUSES)[number];
export type ReadStatusSource = "auto" | "manual";
@@ -217,6 +222,7 @@ export type BookRecommendation = {
hasCover: boolean;
authors: string[];
isAudiobook?: boolean;
isComic?: boolean;
};
export type SeriesBookRecommendation = {
@@ -226,6 +232,7 @@ export type SeriesBookRecommendation = {
hasCover: boolean;
authors: string[];
isAudiobook?: boolean;
isComic?: boolean;
};
export type CoverSearchResult = {
@@ -49,6 +49,7 @@ export interface DisplayPreferences {
tableZebraStriping: boolean;
tableDensity: TableDensity;
bookSpineOverlay: BookSpineOverlay;
showSpineOnComics: boolean;
bookShadowStrength: BookShadowStrength;
bookCoverDisplayMode: BookCoverDisplayMode;
seriesCardCoverMode: SeriesCardCoverMode;
@@ -303,8 +303,8 @@ describe('BookRepository', () => {
const result = await repo.findRecommendationTitlesByBookIds([10, 11]);
expect(result).toEqual([
{ id: 10, title: 'Dune', hasCover: true, authors: ['Frank Herbert'], isAudiobook: true },
{ id: 11, title: 'Foundation', hasCover: false, authors: ['Isaac Asimov', 'Robert Heinlein'], isAudiobook: false },
{ id: 10, title: 'Dune', hasCover: true, authors: ['Frank Herbert'], isAudiobook: true, isComic: false },
{ id: 11, title: 'Foundation', hasCover: false, authors: ['Isaac Asimov', 'Robert Heinlein'], isAudiobook: false, isComic: false },
]);
});
@@ -317,7 +317,7 @@ describe('BookRepository', () => {
const result = await repo.findRecommendationTitlesByBookIds([5]);
expect(result).toEqual([{ id: 5, title: 'No Cover', hasCover: false, authors: [], isAudiobook: false }]);
expect(result).toEqual([{ id: 5, title: 'No Cover', hasCover: false, authors: [], isAudiobook: false, isComic: false }]);
});
it('treats primary format checks as case-insensitive in recommendation rows', async () => {
@@ -329,7 +329,19 @@ describe('BookRepository', () => {
const result = await repo.findRecommendationTitlesByBookIds([6]);
expect(result).toEqual([{ id: 6, title: 'Audio Case', hasCover: true, authors: [], isAudiobook: true }]);
expect(result).toEqual([{ id: 6, title: 'Audio Case', hasCover: true, authors: [], isAudiobook: true, isComic: false }]);
});
it('flags comic primary formats as isComic in recommendation rows', async () => {
const bookRows = [{ id: 8, title: 'Comic Case', coverSource: 'custom', primaryFormat: 'CBR' }];
const db = {
select: vi.fn().mockReturnValueOnce(makeSelectChain('where', bookRows)).mockReturnValueOnce(makeSelectChain('where', [])),
};
const repo = new BookRepository(db as never);
const result = await repo.findRecommendationTitlesByBookIds([8]);
expect(result).toEqual([{ id: 8, title: 'Comic Case', hasCover: true, authors: [], isAudiobook: false, isComic: true }]);
});
it('maps id-list helper rows and primary-file lookups', async () => {
@@ -360,7 +372,7 @@ describe('BookRepository', () => {
await expect(repo.findLibraryIdsByBookIds([1])).resolves.toEqual(libraryRows);
await expect(repo.findRecommendationTitlesByBookIds([1])).resolves.toEqual([
{ id: 1, title: 'Dune', hasCover: true, authors: ['Frank Herbert'], isAudiobook: true },
{ id: 1, title: 'Dune', hasCover: true, authors: ['Frank Herbert'], isAudiobook: true, isComic: false },
]);
await expect(repo.findPrimaryFilesByBookIds([1])).resolves.toEqual(primaryFilesByIds);
await expect(repo.findAllFilesByBookIds([1])).resolves.toEqual(allFilesByIds);
+3 -2
View File
@@ -4,7 +4,7 @@ import { SUPPORTED_BOOK_FORMATS } from '../upload/upload-validator.service';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { ContentFilterRules, JumpBucketsResponse, SortSpec } from '@bookorbit/types';
import { isAudioFormat } from '@bookorbit/types';
import { isAudioFormat, isComicFormat } from '@bookorbit/types';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { SeriesIdentityService } from '../../common/services/series-identity.service';
import { BookQueryBuilder } from './book-query-builder.service';
@@ -949,7 +949,7 @@ export class BookRepository {
async findRecommendationTitlesByBookIds(
bookIds: number[],
): Promise<{ id: number; title: string | null; hasCover: boolean; authors: string[]; isAudiobook: boolean }[]> {
): Promise<{ id: number; title: string | null; hasCover: boolean; authors: string[]; isAudiobook: boolean; isComic: boolean }[]> {
if (bookIds.length === 0) return [];
const [rows, authorRows] = await Promise.all([
@@ -979,6 +979,7 @@ export class BookRepository {
hasCover: r.coverSource !== null,
authors: authorsByBook.get(r.id) ?? [],
isAudiobook: r.primaryFormat != null ? isAudioFormat(r.primaryFormat) : false,
isComic: r.primaryFormat != null ? isComicFormat(r.primaryFormat) : false,
}));
}
@@ -243,8 +243,8 @@ describe('RecommendationRepository', () => {
const result = await repo.findSeriesBooks(88, [3, 4]);
expect(result).toEqual([
{ bookId: 1, title: 'Book 1', seriesIndex: 1, coverSource: 'extracted', authorNames: ['Frank Herbert'], isAudiobook: true },
{ bookId: 2, title: 'Book 2', seriesIndex: 2, coverSource: null, authorNames: [], isAudiobook: false },
{ bookId: 1, title: 'Book 1', seriesIndex: 1, coverSource: 'extracted', authorNames: ['Frank Herbert'], isAudiobook: true, isComic: false },
{ bookId: 2, title: 'Book 2', seriesIndex: 2, coverSource: null, authorNames: [], isAudiobook: false, isComic: false },
]);
expect(select).toHaveBeenCalledTimes(2);
expect(chains[0].from).toHaveBeenCalledTimes(1);
@@ -264,7 +264,9 @@ describe('RecommendationRepository', () => {
const result = await repo.findSeriesBooks(88, [1]);
expect(result).toEqual([{ bookId: 5, title: 'Solo Book', seriesIndex: null, coverSource: null, authorNames: [], isAudiobook: false }]);
expect(result).toEqual([
{ bookId: 5, title: 'Solo Book', seriesIndex: null, coverSource: null, authorNames: [], isAudiobook: false, isComic: false },
]);
});
it('returns empty author books when libraryIds is empty', async () => {
@@ -289,7 +291,9 @@ describe('RecommendationRepository', () => {
const result = await repo.findAuthorBooks(1, [3]);
expect(result).toEqual([{ bookId: 10, title: 'Other Book', coverSource: 'extracted', authorNames: ['Terry Pratchett'], isAudiobook: true }]);
expect(result).toEqual([
{ bookId: 10, title: 'Other Book', coverSource: 'extracted', authorNames: ['Terry Pratchett'], isAudiobook: true, isComic: false },
]);
expect(select).toHaveBeenCalledTimes(3);
expect(chains[1].innerJoin).toHaveBeenCalledTimes(1);
expect(chains[1].leftJoin).toHaveBeenCalledTimes(2);
@@ -307,6 +311,21 @@ describe('RecommendationRepository', () => {
const result = await repo.findAuthorBooks(1, [1]);
expect(result).toEqual([{ bookId: 7, title: 'Anonymous Work', coverSource: null, authorNames: [], isAudiobook: false }]);
expect(result).toEqual([{ bookId: 7, title: 'Anonymous Work', coverSource: null, authorNames: [], isAudiobook: false, isComic: false }]);
});
it('flags comic primary formats as isComic for series books', async () => {
const rows = [{ bookId: 9, title: 'Comic Issue', seriesIndex: 3, coverSource: 'extracted', primaryFormat: 'CBZ' }];
const { db } = makeDb([
{ terminal: 'limit', result: rows },
{ terminal: 'where', result: [] },
]);
const repo = new RecommendationRepository(db);
const result = await repo.findSeriesBooks(88, [1]);
expect(result).toEqual([
{ bookId: 9, title: 'Comic Issue', seriesIndex: 3, coverSource: 'extracted', authorNames: [], isAudiobook: false, isComic: true },
]);
});
});
@@ -3,7 +3,7 @@ import { eq, inArray, ne, and, isNotNull, sql, asc, desc } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { ContentFilterRules } from '@bookorbit/types';
import { isAudioFormat } from '@bookorbit/types';
import { isAudioFormat, isComicFormat } from '@bookorbit/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookGenres, bookMetadata, bookTags, books, genres, tags } from '../../db/schema';
@@ -21,6 +21,7 @@ export interface SeriesBookRow {
coverSource: string | null;
authorNames: string[];
isAudiobook: boolean;
isComic: boolean;
}
export interface AuthorBookRow {
@@ -29,6 +30,7 @@ export interface AuthorBookRow {
coverSource: string | null;
authorNames: string[];
isAudiobook: boolean;
isComic: boolean;
}
export interface AnnCandidate {
@@ -196,6 +198,7 @@ export class RecommendationRepository {
coverSource: r.coverSource,
authorNames: authorsByBook.get(r.bookId) ?? [],
isAudiobook: r.primaryFormat != null ? isAudioFormat(r.primaryFormat) : false,
isComic: r.primaryFormat != null ? isComicFormat(r.primaryFormat) : false,
}));
}
@@ -240,6 +243,7 @@ export class RecommendationRepository {
coverSource: r.coverSource,
authorNames: authorsByBook.get(r.bookId) ?? [],
isAudiobook: r.primaryFormat != null ? isAudioFormat(r.primaryFormat) : false,
isComic: r.primaryFormat != null ? isComicFormat(r.primaryFormat) : false,
}));
}
@@ -92,7 +92,10 @@ export class RecommendationService {
const rowMap = new Map(rows.map((row) => [row.id, row]));
const recommendations = rescored
.map((rescoredCandidate) => rowMap.get(rescoredCandidate.bookId))
.filter((row): row is { id: number; title: string | null; hasCover: boolean; authors: string[]; isAudiobook: boolean } => row != null);
.filter(
(row): row is { id: number; title: string | null; hasCover: boolean; authors: string[]; isAudiobook: boolean; isComic: boolean } =>
row != null,
);
this.logger.log(
`[${RECOMMENDATION_EVENT}] [end] bookId=${bookId} userId=${user.id} libraryId=${libraryId} durationMs=${Date.now() - startedAt} accessibleLibraryCount=${accessibleLibraryIds.length} candidateCount=${candidates.length} rescoredCount=${rescored.length} resultCount=${recommendations.length} - recommendation lookup completed`,
@@ -139,6 +142,7 @@ export class RecommendationService {
hasCover: r.coverSource !== null,
authors: r.authorNames,
isAudiobook: r.isAudiobook,
isComic: r.isComic,
}));
} catch (err) {
const { errorClass, errorMessage } = this.parseError(err);
@@ -171,6 +175,7 @@ export class RecommendationService {
hasCover: r.coverSource !== null,
authors: r.authorNames,
isAudiobook: r.isAudiobook,
isComic: r.isComic,
}));
} catch (err) {
const { errorClass, errorMessage } = this.parseError(err);
@@ -28,6 +28,7 @@ const validDisplayPreferences: DisplayPreferences = {
tableZebraStriping: false,
tableDensity: 'comfortable',
bookSpineOverlay: 'subtle',
showSpineOnComics: false,
bookShadowStrength: 'strong',
bookCoverDisplayMode: 'natural-bottom',
seriesCardCoverMode: 'stack',
@@ -195,6 +196,31 @@ describe('UserPreferencesService', () => {
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ seriesCardCoverMode: 'stack' }));
});
it('upsertDisplayPreferences accepts both showSpineOnComics values', async () => {
for (const value of [true, false]) {
await expect(
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, showSpineOnComics: value } as unknown as Record<string, unknown>),
).resolves.toBeUndefined();
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ showSpineOnComics: value }));
}
});
it('upsertDisplayPreferences defaults showSpineOnComics to false when omitted', async () => {
const { showSpineOnComics, ...withoutFlag } = validDisplayPreferences;
void showSpineOnComics;
await expect(service.upsertDisplayPreferences(11, withoutFlag as unknown as Record<string, unknown>)).resolves.toBeUndefined();
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ showSpineOnComics: false }));
});
it('upsertDisplayPreferences rejects non-boolean showSpineOnComics', async () => {
await expect(
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, showSpineOnComics: 'yes' } as unknown as Record<string, unknown>),
).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertDisplayPreferences rejects invalid cover display modes', async () => {
await expect(
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, bookCoverDisplayMode: 'stretched' } as never),
@@ -50,6 +50,7 @@ const DISPLAY_PREFERENCES_SCHEMA = z
tableZebraStriping: z.boolean(),
tableDensity: z.enum(TABLE_DENSITIES),
bookSpineOverlay: z.enum(BOOK_SPINE_OVERLAYS),
showSpineOnComics: z.boolean().default(false),
bookShadowStrength: z.enum(BOOK_SHADOW_STRENGTHS),
bookCoverDisplayMode: z.enum(BOOK_COVER_DISPLAY_MODES),
seriesCardCoverMode: z.enum(SERIES_CARD_COVER_MODES).default('stack'),