mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): add ambient book cover tinting to book detail view
Extract dominant color from book covers to apply ambient background tinting in BookDetailView, add cover tinting toggle to display settings, and test cover tint extraction.
This commit is contained in:
@@ -55,6 +55,32 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Colour pulled from the book's own cover, strongest behind the artwork and
|
||||
title and gone before the body copy. Alphas are tuned per theme: the same
|
||||
hue needs more presence over a dark card than over a light one.
|
||||
|
||||
The two tones glow from opposite top corners and overlap in the middle, which
|
||||
reads as one soft field rather than two stripes. A cover with no distinct
|
||||
second tone leaves the -2 variables inheriting the first, so the pair
|
||||
collapses back into a single wash. */
|
||||
.book-detail-cover-tint {
|
||||
--cover-tint-near: 0.24;
|
||||
--cover-tint-mid: 0.16;
|
||||
--cover-tint-far: 0.05;
|
||||
--cover-tint-hue-2: var(--cover-tint-hue);
|
||||
--cover-tint-saturation-2: var(--cover-tint-saturation);
|
||||
background-image:
|
||||
radial-gradient(78% 46% at 12% 0%, hsl(var(--cover-tint-hue) var(--cover-tint-saturation) 50% / var(--cover-tint-near)) 0%, transparent 72%),
|
||||
radial-gradient(78% 46% at 88% 0%, hsl(var(--cover-tint-hue-2) var(--cover-tint-saturation-2) 50% / var(--cover-tint-mid)) 0%, transparent 72%),
|
||||
linear-gradient(to bottom, hsl(var(--cover-tint-hue) var(--cover-tint-saturation) 50% / var(--cover-tint-far)) 0%, transparent 55%);
|
||||
}
|
||||
|
||||
.dark .book-detail-cover-tint {
|
||||
--cover-tint-near: 0.34;
|
||||
--cover-tint-mid: 0.24;
|
||||
--cover-tint-far: 0.08;
|
||||
}
|
||||
|
||||
.book-cover-artwork-frame {
|
||||
position: absolute;
|
||||
border-radius: inherit;
|
||||
|
||||
@@ -22,6 +22,7 @@ function resetDisplaySettings() {
|
||||
settings.showSpineOnComics.value = false
|
||||
settings.bookShadowStrength.value = 'default'
|
||||
settings.bookCoverDisplayMode.value = 'blurred-fit'
|
||||
settings.bookDetailCoverTint.value = 'single'
|
||||
settings.seriesCardCoverMode.value = 'stack'
|
||||
settings.gridCardPrimaryLabel.value = 'hidden'
|
||||
settings.gridCardSecondaryLabel.value = 'hidden'
|
||||
@@ -201,6 +202,26 @@ describe('useDisplaySettings preferences helpers', () => {
|
||||
expect(settings.showSpineOnComics.value).toBe(true)
|
||||
})
|
||||
|
||||
it('includes bookDetailCoverTint in snapshot', () => {
|
||||
settings.bookDetailCoverTint.value = 'duotone'
|
||||
expect(getDisplayPreferencesSnapshot().bookDetailCoverTint).toBe('duotone')
|
||||
})
|
||||
|
||||
it('defaults bookDetailCoverTint to single', () => {
|
||||
resetDisplaySettings()
|
||||
expect(settings.bookDetailCoverTint.value).toBe('single')
|
||||
})
|
||||
|
||||
it('sanitizes bookDetailCoverTint and drops unknown values', () => {
|
||||
expect(sanitizeDisplayPreferences({ bookDetailCoverTint: 'off' })).toEqual({ bookDetailCoverTint: 'off' })
|
||||
expect(sanitizeDisplayPreferences({ bookDetailCoverTint: 'rainbow' })).toEqual({})
|
||||
})
|
||||
|
||||
it('applies bookDetailCoverTint from preferences', () => {
|
||||
applyDisplayPreferences({ bookDetailCoverTint: 'off' })
|
||||
expect(settings.bookDetailCoverTint.value).toBe('off')
|
||||
})
|
||||
|
||||
it('includes thumbnailClickAction in snapshot', () => {
|
||||
settings.thumbnailClickAction.value = 'details'
|
||||
const snap = getDisplayPreferencesSnapshot()
|
||||
|
||||
@@ -22,6 +22,7 @@ function validDisplayPreferences(overrides: Partial<DisplayPreferences> = {}): D
|
||||
showSpineOnComics: false,
|
||||
bookShadowStrength: 'strong',
|
||||
bookCoverDisplayMode: 'blurred-fit',
|
||||
bookDetailCoverTint: 'duotone',
|
||||
cardInfoMode: 'hover-overlay',
|
||||
seriesCardCoverMode: 'stack',
|
||||
gridCardPrimaryLabel: 'hidden',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ref, watch } from 'vue'
|
||||
import {
|
||||
AUTHOR_COVER_SHAPES,
|
||||
BOOK_COVER_DISPLAY_MODES,
|
||||
BOOK_DETAIL_COVER_TINTS,
|
||||
BOOK_SHADOW_STRENGTHS,
|
||||
BOOK_SPINE_OVERLAYS,
|
||||
BOOK_THUMBNAIL_CLICK_ACTION,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
TABLE_DENSITIES,
|
||||
type AuthorCoverShape,
|
||||
type BookCoverDisplayMode,
|
||||
type BookDetailCoverTint,
|
||||
type BookShadowStrength,
|
||||
type BookSpineOverlay,
|
||||
type BookThumbnailClickAction,
|
||||
@@ -31,6 +33,7 @@ import { storage } from '@/services/storage'
|
||||
export type {
|
||||
AuthorCoverShape,
|
||||
BookCoverDisplayMode,
|
||||
BookDetailCoverTint,
|
||||
BookShadowStrength,
|
||||
BookSpineOverlay,
|
||||
BookThumbnailClickAction,
|
||||
@@ -50,6 +53,7 @@ const DEFAULT_GRID_GAP = 28
|
||||
const DEFAULT_BOOK_SPINE_OVERLAY: BookSpineOverlay = 'off'
|
||||
const DEFAULT_BOOK_SHADOW_STRENGTH: BookShadowStrength = 'default'
|
||||
const DEFAULT_BOOK_COVER_DISPLAY_MODE: BookCoverDisplayMode = 'blurred-fit'
|
||||
const DEFAULT_BOOK_DETAIL_COVER_TINT: BookDetailCoverTint = 'single'
|
||||
const DEFAULT_SERIES_CARD_COVER_MODE: SeriesCardCoverMode = 'stack'
|
||||
const DEFAULT_BOOK_THUMBNAIL_CLICK_ACTION: BookThumbnailClickAction = 'reader'
|
||||
const DEFAULT_CARD_OVERLAYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'series-position']
|
||||
@@ -72,6 +76,12 @@ function normalizeBookCoverDisplayMode(value: unknown): BookCoverDisplayMode {
|
||||
: DEFAULT_BOOK_COVER_DISPLAY_MODE
|
||||
}
|
||||
|
||||
function normalizeBookDetailCoverTint(value: unknown): BookDetailCoverTint {
|
||||
return typeof value === 'string' && BOOK_DETAIL_COVER_TINTS.includes(value as BookDetailCoverTint)
|
||||
? (value as BookDetailCoverTint)
|
||||
: DEFAULT_BOOK_DETAIL_COVER_TINT
|
||||
}
|
||||
|
||||
function normalizeSeriesCardCoverMode(value: unknown): SeriesCardCoverMode {
|
||||
return typeof value === 'string' && SERIES_CARD_COVER_MODES.includes(value as SeriesCardCoverMode)
|
||||
? (value as SeriesCardCoverMode)
|
||||
@@ -152,6 +162,7 @@ const bookShadowStrength = ref<BookShadowStrength>(normalizeBookShadowStrength(s
|
||||
const bookCoverDisplayMode = ref<BookCoverDisplayMode>(
|
||||
normalizeBookCoverDisplayMode(storage.get('bookCoverDisplayMode', DEFAULT_BOOK_COVER_DISPLAY_MODE)),
|
||||
)
|
||||
const bookDetailCoverTint = ref<BookDetailCoverTint>(normalizeBookDetailCoverTint(storage.get('bookDetailCoverTint', DEFAULT_BOOK_DETAIL_COVER_TINT)))
|
||||
const seriesCardCoverMode = ref<SeriesCardCoverMode>(normalizeSeriesCardCoverMode(storage.get('seriesCardCoverMode', DEFAULT_SERIES_CARD_COVER_MODE)))
|
||||
const gridCardPrimaryLabel = ref<GridCardLabelField>(normalizeGridCardLabelField(storage.get('gridCardPrimaryLabel', 'hidden')))
|
||||
const gridCardSecondaryLabel = ref<GridCardLabelField>(normalizeGridCardLabelField(storage.get('gridCardSecondaryLabel', 'hidden')))
|
||||
@@ -178,6 +189,7 @@ watch(bookSpineOverlay, (value) => storage.set('bookSpineOverlay', normalizeBook
|
||||
watch(showSpineOnComics, (v) => storage.set('showSpineOnComics', v))
|
||||
watch(bookShadowStrength, (value) => storage.set('bookShadowStrength', normalizeBookShadowStrength(value)))
|
||||
watch(bookCoverDisplayMode, (value) => storage.set('bookCoverDisplayMode', normalizeBookCoverDisplayMode(value)))
|
||||
watch(bookDetailCoverTint, (value) => storage.set('bookDetailCoverTint', normalizeBookDetailCoverTint(value)))
|
||||
watch(seriesCardCoverMode, (value) => storage.set('seriesCardCoverMode', normalizeSeriesCardCoverMode(value)))
|
||||
watch(gridCardPrimaryLabel, (value) => storage.set('gridCardPrimaryLabel', normalizeGridCardLabelField(value)))
|
||||
watch(gridCardSecondaryLabel, (value) => storage.set('gridCardSecondaryLabel', normalizeGridCardLabelField(value)))
|
||||
@@ -204,6 +216,7 @@ export function getDisplayPreferencesSnapshot(): DisplayPreferences {
|
||||
showSpineOnComics: showSpineOnComics.value === true,
|
||||
bookShadowStrength: normalizeBookShadowStrength(bookShadowStrength.value),
|
||||
bookCoverDisplayMode: normalizeBookCoverDisplayMode(bookCoverDisplayMode.value),
|
||||
bookDetailCoverTint: normalizeBookDetailCoverTint(bookDetailCoverTint.value),
|
||||
seriesCardCoverMode: normalizeSeriesCardCoverMode(seriesCardCoverMode.value),
|
||||
gridCardPrimaryLabel: normalizeGridCardLabelField(gridCardPrimaryLabel.value),
|
||||
gridCardSecondaryLabel: normalizeGridCardLabelField(gridCardSecondaryLabel.value),
|
||||
@@ -240,6 +253,9 @@ export function sanitizeDisplayPreferences(raw: unknown): Partial<DisplayPrefere
|
||||
if (BOOK_COVER_DISPLAY_MODES.includes(obj.bookCoverDisplayMode as BookCoverDisplayMode)) {
|
||||
out.bookCoverDisplayMode = obj.bookCoverDisplayMode as BookCoverDisplayMode
|
||||
}
|
||||
if (BOOK_DETAIL_COVER_TINTS.includes(obj.bookDetailCoverTint as BookDetailCoverTint)) {
|
||||
out.bookDetailCoverTint = obj.bookDetailCoverTint as BookDetailCoverTint
|
||||
}
|
||||
if (SERIES_CARD_COVER_MODES.includes(obj.seriesCardCoverMode as SeriesCardCoverMode)) {
|
||||
out.seriesCardCoverMode = obj.seriesCardCoverMode as SeriesCardCoverMode
|
||||
}
|
||||
@@ -279,6 +295,7 @@ export function applyDisplayPreferences(raw: unknown): void {
|
||||
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.bookDetailCoverTint !== undefined) bookDetailCoverTint.value = prefs.bookDetailCoverTint
|
||||
if (prefs.seriesCardCoverMode !== undefined) seriesCardCoverMode.value = prefs.seriesCardCoverMode
|
||||
if (prefs.gridCardPrimaryLabel !== undefined) gridCardPrimaryLabel.value = prefs.gridCardPrimaryLabel
|
||||
if (prefs.gridCardSecondaryLabel !== undefined) gridCardSecondaryLabel.value = prefs.gridCardSecondaryLabel
|
||||
@@ -306,6 +323,7 @@ export function useDisplaySettings() {
|
||||
showSpineOnComics,
|
||||
bookShadowStrength,
|
||||
bookCoverDisplayMode,
|
||||
bookDetailCoverTint,
|
||||
seriesCardCoverMode,
|
||||
gridCardPrimaryLabel,
|
||||
gridCardSecondaryLabel,
|
||||
|
||||
@@ -119,6 +119,7 @@ export function initDisplaySettingsSync(): void {
|
||||
settings.showSpineOnComics.value,
|
||||
settings.bookShadowStrength.value,
|
||||
settings.bookCoverDisplayMode.value,
|
||||
settings.bookDetailCoverTint.value,
|
||||
settings.seriesCardCoverMode.value,
|
||||
settings.gridCardPrimaryLabel.value,
|
||||
settings.gridCardSecondaryLabel.value,
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock('@/lib/api', () => ({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
})),
|
||||
setOnAuthFailure: vi.fn<(callback: () => void) => void>(),
|
||||
}))
|
||||
vi.mock('@/features/book/composables/useCoverVersions', () => ({
|
||||
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn<(...args: unknown[]) => void>() }),
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import BookDetailHeader from './BookDetailHeader.vue'
|
||||
import { coverTintVars, type CoverTint } from '@/features/book/lib/cover-tint'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineProps<{ bookId: number }>()
|
||||
const props = withDefaults(defineProps<{ bookId: number; coverTint?: CoverTint | null }>(), { coverTint: null })
|
||||
|
||||
const tintVars = computed(() => coverTintVars(props.coverTint))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col mt-2 mb-0 h-[calc(100%-0.5rem)] overflow-hidden rounded-lg border border-border/70 bg-card/60 shadow-sm">
|
||||
<div
|
||||
:class="[
|
||||
'flex flex-col mt-2 mb-0 h-[calc(100%-0.5rem)] overflow-hidden rounded-lg border border-border/70 bg-card/60 shadow-sm',
|
||||
coverTint ? 'book-detail-cover-tint' : '',
|
||||
]"
|
||||
:style="tintVars"
|
||||
>
|
||||
<BookDetailHeader :book-id="bookId" />
|
||||
<main class="flex-1 overflow-y-auto overflow-x-hidden p-4 sm:p-6">
|
||||
<slot />
|
||||
|
||||
@@ -141,58 +141,14 @@ const coverFailed = ref(false)
|
||||
const coverImageRatio = ref<number | null>(null)
|
||||
const coverLightboxOpen = ref(false)
|
||||
const descriptionExpanded = ref(false)
|
||||
const genresExpanded = ref(false)
|
||||
const genreMeasureContainer = ref<HTMLElement | null>(null)
|
||||
const genreHiddenCount = ref(0)
|
||||
const visibleGenreCount = ref(0)
|
||||
const safeDescription = useSafeHtml(() => props.book.description)
|
||||
const filledCustomMetadata = computed(() => (props.book.customMetadata ?? []).filter((field) => field.value !== null && field.value !== ''))
|
||||
const displayedGenres = computed(() => {
|
||||
if (genresExpanded.value || genreHiddenCount.value === 0) return props.book.genres
|
||||
const count = visibleGenreCount.value > 0 ? visibleGenreCount.value : props.book.genres.length
|
||||
return props.book.genres.slice(0, count)
|
||||
})
|
||||
const MORE_BUTTON_RESERVED_WIDTH = 72
|
||||
|
||||
function getUniqueRowTops(values: number[]): number[] {
|
||||
const rows: number[] = []
|
||||
for (const value of values) {
|
||||
if (!rows.some((rowTop) => Math.abs(rowTop - value) <= 1)) rows.push(value)
|
||||
}
|
||||
return rows.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
function getRowTop(metrics: Array<{ top: number; right: number }>): number | null {
|
||||
const tops = getUniqueRowTops(metrics.map((metric) => metric.top))
|
||||
return tops.length > 0 ? tops[0]! : null
|
||||
}
|
||||
|
||||
function getRowRight(metrics: Array<{ top: number; right: number }>, rowTop: number): number {
|
||||
const rowItems = metrics.filter((metric) => Math.abs(metric.top - rowTop) <= 1)
|
||||
return rowItems.length ? Math.max(...rowItems.map((metric) => metric.right)) : 0
|
||||
}
|
||||
|
||||
function ensureMoreButtonFitsSecondRow(
|
||||
metrics: Array<{ top: number; right: number }>,
|
||||
secondRowTop: number,
|
||||
containerWidth: number,
|
||||
initialVisibleCount: number,
|
||||
) {
|
||||
let visibleCount = initialVisibleCount
|
||||
let hiddenCount = metrics.length - visibleCount
|
||||
|
||||
while (hiddenCount > 0 && visibleCount > 0) {
|
||||
const visibleMetrics = metrics.slice(0, visibleCount)
|
||||
const currentSecondRowTop = getRowTop(visibleMetrics.filter((metric) => metric.top >= secondRowTop - 1))
|
||||
const rowTop = currentSecondRowTop ?? secondRowTop
|
||||
const remainingWidth = containerWidth - getRowRight(visibleMetrics, rowTop)
|
||||
if (remainingWidth >= MORE_BUTTON_RESERVED_WIDTH) break
|
||||
visibleCount -= 1
|
||||
hiddenCount += 1
|
||||
}
|
||||
|
||||
return { visibleCount, hiddenCount }
|
||||
}
|
||||
const displayedGenres = computed(() => props.book.genres.slice(0, visibleGenreCount.value))
|
||||
const hiddenGenres = computed(() => props.book.genres.slice(visibleGenreCount.value))
|
||||
const GENRE_GAP_PX = 6
|
||||
|
||||
function resetGenreFoldState() {
|
||||
visibleGenreCount.value = props.book.genres.length
|
||||
@@ -207,34 +163,32 @@ function measureGenreOverflow() {
|
||||
}
|
||||
|
||||
const pills = Array.from(container.querySelectorAll<HTMLElement>('[data-genre-pill="true"]'))
|
||||
if (pills.length === 0) {
|
||||
resetGenreFoldState()
|
||||
return
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const moreButton = container.querySelector<HTMLElement>('[data-genre-more-measure="true"]')
|
||||
const containerWidth = container.clientWidth
|
||||
const pillMetrics = pills.map((pill) => {
|
||||
const rect = pill.getBoundingClientRect()
|
||||
return {
|
||||
top: rect.top - containerRect.top,
|
||||
right: rect.right - containerRect.left,
|
||||
}
|
||||
})
|
||||
|
||||
const rowTops = getUniqueRowTops(pillMetrics.map((metric) => metric.top))
|
||||
if (rowTops.length <= 2) {
|
||||
if (pills.length === 0 || !moreButton || containerWidth <= 0) {
|
||||
resetGenreFoldState()
|
||||
return
|
||||
}
|
||||
|
||||
const secondRowTop = rowTops[1]!
|
||||
let visibleCount = pillMetrics.findIndex((metric) => metric.top > secondRowTop + 1)
|
||||
if (visibleCount === -1) visibleCount = pillMetrics.length
|
||||
const pillWidths = pills.map((pill) => pill.getBoundingClientRect().width)
|
||||
const allPillsWidth = pillWidths.reduce((total, width) => total + width, 0) + GENRE_GAP_PX * Math.max(0, pills.length - 1)
|
||||
if (allPillsWidth <= containerWidth) {
|
||||
resetGenreFoldState()
|
||||
return
|
||||
}
|
||||
|
||||
const fitted = ensureMoreButtonFitsSecondRow(pillMetrics, secondRowTop, containerWidth, visibleCount)
|
||||
visibleGenreCount.value = fitted.visibleCount
|
||||
genreHiddenCount.value = fitted.hiddenCount
|
||||
const moreButtonWidth = moreButton.getBoundingClientRect().width
|
||||
let usedWidth = 0
|
||||
let visibleCount = 0
|
||||
for (const pillWidth of pillWidths) {
|
||||
const nextPillWidth = usedWidth + (visibleCount > 0 ? GENRE_GAP_PX : 0) + pillWidth
|
||||
if (nextPillWidth + GENRE_GAP_PX + moreButtonWidth > containerWidth) break
|
||||
usedWidth = nextPillWidth
|
||||
visibleCount += 1
|
||||
}
|
||||
|
||||
visibleGenreCount.value = visibleCount
|
||||
genreHiddenCount.value = pills.length - visibleCount
|
||||
}
|
||||
|
||||
let genreResizeObserver: ResizeObserver | null = null
|
||||
@@ -259,7 +213,6 @@ function formatCustomMetadataValue(field: CustomMetadataBookValue): string {
|
||||
watch(
|
||||
() => `${props.book.id}:${props.book.genres.join('|')}`,
|
||||
() => {
|
||||
genresExpanded.value = false
|
||||
resetGenreFoldState()
|
||||
scheduleGenreOverflowMeasure()
|
||||
},
|
||||
@@ -2060,36 +2013,60 @@ watch(
|
||||
<!-- Genres + Tags -->
|
||||
<div v-if="book.genres.length || book.tags.length" class="mt-4 space-y-1.5">
|
||||
<div v-if="book.genres.length" class="relative">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<div data-test="genre-row" class="flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap">
|
||||
<span
|
||||
v-for="(genre, index) in displayedGenres"
|
||||
:key="`${genre}-${index}`"
|
||||
class="text-xs px-2.5 py-0.5 rounded-full border border-primary/40 text-primary"
|
||||
data-test="visible-genre"
|
||||
class="shrink-0 rounded-full border border-primary/40 px-2.5 py-0.5 text-xs text-primary"
|
||||
>
|
||||
{{ genre }}
|
||||
</span>
|
||||
<button
|
||||
v-if="genreHiddenCount > 0"
|
||||
type="button"
|
||||
class="text-xs font-medium text-foreground hover:text-foreground transition-colors whitespace-nowrap"
|
||||
@click="genresExpanded = !genresExpanded"
|
||||
>
|
||||
{{ genresExpanded ? t('book.detail.details.showLess') : t('book.detail.details.moreCount', { count: genreHiddenCount }) }}
|
||||
</button>
|
||||
<Popover v-if="genreHiddenCount > 0">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
data-test="genre-overflow-trigger"
|
||||
class="shrink-0 whitespace-nowrap rounded-full border border-border px-2.5 py-0.5 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
:aria-label="t('book.detail.details.moreCount', { count: genreHiddenCount })"
|
||||
>
|
||||
+{{ genreHiddenCount }}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-80 max-w-[calc(100vw-2rem)] p-3">
|
||||
<div data-test="hidden-genres" class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="(genre, index) in hiddenGenres"
|
||||
:key="`hidden-${genre}-${index}`"
|
||||
class="rounded-full border border-primary/40 px-2.5 py-0.5 text-xs text-primary"
|
||||
>
|
||||
{{ genre }}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div
|
||||
ref="genreMeasureContainer"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute left-0 top-0 -z-10 invisible flex w-full flex-wrap gap-1.5"
|
||||
class="pointer-events-none invisible absolute left-0 top-0 -z-10 flex w-full items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<span
|
||||
v-for="(genre, index) in book.genres"
|
||||
:key="`measure-${genre}-${index}`"
|
||||
data-genre-pill="true"
|
||||
class="text-xs px-2.5 py-0.5 rounded-full border border-primary/40 text-primary"
|
||||
class="shrink-0 rounded-full border border-primary/40 px-2.5 py-0.5 text-xs text-primary"
|
||||
>
|
||||
{{ genre }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
data-genre-more-measure="true"
|
||||
class="shrink-0 whitespace-nowrap rounded-full border border-border px-2.5 py-0.5 text-xs font-medium"
|
||||
>
|
||||
+{{ book.genres.length }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -275,6 +275,35 @@ describe('DetailsTab cover surface', () => {
|
||||
expect(wrapper.text()).toContain('Author One, Author Two')
|
||||
})
|
||||
|
||||
it('keeps genres on one line and moves genres that do not fit into the overflow popover', async () => {
|
||||
const wrapper = mountDetails(
|
||||
makeBook({
|
||||
genres: ['Fantasy', 'Adventure', 'Romance', 'Mystery'],
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const measureButton = wrapper.get<HTMLElement>('[data-genre-more-measure="true"]')
|
||||
const measureContainer = measureButton.element.parentElement as HTMLElement
|
||||
Object.defineProperty(measureContainer, 'clientWidth', { configurable: true, value: 300 })
|
||||
|
||||
for (const pill of wrapper.findAll<HTMLElement>('[data-genre-pill="true"]')) {
|
||||
vi.spyOn(pill.element, 'getBoundingClientRect').mockReturnValue({ width: 80 } as DOMRect)
|
||||
}
|
||||
vi.spyOn(measureButton.element, 'getBoundingClientRect').mockReturnValue({ width: 40 } as DOMRect)
|
||||
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await flushPromises()
|
||||
|
||||
const row = wrapper.get('[data-test="genre-row"]')
|
||||
expect(row.classes()).toContain('whitespace-nowrap')
|
||||
expect(row.classes()).not.toContain('flex-wrap')
|
||||
expect(wrapper.findAll('[data-test="visible-genre"]').map((pill) => pill.text())).toEqual(['Fantasy', 'Adventure', 'Romance'])
|
||||
expect(wrapper.get('[data-test="genre-overflow-trigger"]').text()).toBe('+1')
|
||||
expect(wrapper.get('[data-test="genre-overflow-trigger"]').attributes('aria-label')).toBe('+1 more')
|
||||
expect(wrapper.get('[data-test="hidden-genres"]').text()).toContain('Mystery')
|
||||
})
|
||||
|
||||
it('summarizes pending Kobo sync state for each affected device', async () => {
|
||||
mocks.api.mockImplementation(async (input) => {
|
||||
const url = String(input)
|
||||
|
||||
@@ -15,7 +15,7 @@ vi.mock('vue-router', async (importOriginal) => {
|
||||
return { ...actual, useRouter: () => ({ push: mocks.push, back: mocks.back }) }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/api', () => ({ api: mocks.api }))
|
||||
vi.mock('@/lib/api', () => ({ api: mocks.api, setOnAuthFailure: vi.fn<(callback: () => void) => void>() }))
|
||||
vi.mock('@/features/auth/composables/usePermissions', () => ({ usePermissions: () => ({ hasPermission: mocks.hasPermission }) }))
|
||||
|
||||
vi.mock('@/features/book/composables/useDeleteBook', () => ({
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
import { extractCoverTint, type CoverTint } from '../lib/cover-tint'
|
||||
|
||||
// Covers are immutable per URL (the URL carries a version token), so a decoded
|
||||
// result stays valid; revisiting a book costs nothing.
|
||||
const cache = new Map<string, CoverTint | null>()
|
||||
|
||||
export function useCoverTint(src: Ref<string | null>) {
|
||||
const tint = ref<CoverTint | null>(null)
|
||||
let requestId = 0
|
||||
|
||||
function load(url: string | null) {
|
||||
const current = ++requestId
|
||||
if (!url) {
|
||||
tint.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const cached = cache.get(url)
|
||||
if (cached !== undefined) {
|
||||
tint.value = cached
|
||||
return
|
||||
}
|
||||
|
||||
// The cover is already displayed on this page, so this resolves from the
|
||||
// browser cache rather than issuing a second request.
|
||||
const image = new Image()
|
||||
image.decoding = 'async'
|
||||
image.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
const extracted = extractCoverTint(image)
|
||||
cache.set(url, extracted)
|
||||
if (current === requestId) tint.value = extracted
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
image.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
cache.set(url, null)
|
||||
if (current === requestId) tint.value = null
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
image.src = url
|
||||
}
|
||||
|
||||
watch(src, load, { immediate: true })
|
||||
|
||||
return { tint }
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { coverTintVars, extractCoverTint } from './cover-tint'
|
||||
|
||||
type Pixel = [number, number, number]
|
||||
|
||||
const SIZE = 32
|
||||
|
||||
function imageDataFrom(pixels: Pixel[]): Uint8ClampedArray {
|
||||
const data = new Uint8ClampedArray(SIZE * SIZE * 4)
|
||||
for (let i = 0; i < SIZE * SIZE; i += 1) {
|
||||
const [r, g, b] = pixels[i % pixels.length]!
|
||||
data[i * 4] = r
|
||||
data[i * 4 + 1] = g
|
||||
data[i * 4 + 2] = b
|
||||
data[i * 4 + 3] = 255
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
function stubCanvas(result: { data: Uint8ClampedArray } | Error) {
|
||||
const getImageData = vi.fn<() => { data: Uint8ClampedArray }>(() => {
|
||||
if (result instanceof Error) throw result
|
||||
return result
|
||||
})
|
||||
const context = { drawImage: vi.fn<() => void>(), getImageData }
|
||||
vi.stubGlobal('document', {
|
||||
createElement: () => ({ width: 0, height: 0, getContext: () => context }),
|
||||
})
|
||||
return context
|
||||
}
|
||||
|
||||
function image(): HTMLImageElement {
|
||||
return { naturalWidth: 400, naturalHeight: 600 } as HTMLImageElement
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('extractCoverTint', () => {
|
||||
it('returns the hue of the dominant saturated colour', () => {
|
||||
stubCanvas({ data: imageDataFrom([[204, 0, 0]]) })
|
||||
expect(extractCoverTint(image())).toEqual({ primary: { hue: 0, saturation: 70 }, secondary: null })
|
||||
})
|
||||
|
||||
it('ignores near-black and near-white pixels that carry no hue', () => {
|
||||
// Three quarters of the cover is black or white; the blue quarter must win.
|
||||
stubCanvas({
|
||||
data: imageDataFrom([
|
||||
[0, 0, 0],
|
||||
[255, 255, 255],
|
||||
[8, 8, 8],
|
||||
[0, 102, 204],
|
||||
]),
|
||||
})
|
||||
expect(extractCoverTint(image())?.primary.hue).toBe(210)
|
||||
})
|
||||
|
||||
it('clamps saturation into the usable band', () => {
|
||||
stubCanvas({ data: imageDataFrom([[140, 110, 110]]) })
|
||||
expect(extractCoverTint(image())?.primary.saturation).toBe(35)
|
||||
|
||||
stubCanvas({ data: imageDataFrom([[0, 255, 0]]) })
|
||||
expect(extractCoverTint(image())?.primary.saturation).toBe(70)
|
||||
})
|
||||
|
||||
it('keeps shades of one colour together instead of letting a flat minority hue win', () => {
|
||||
// Three quarters of the cover is red across shadow, midtone and highlight;
|
||||
// the remaining quarter is a single flat green. Red has to win.
|
||||
stubCanvas({
|
||||
data: imageDataFrom([
|
||||
[92, 12, 14],
|
||||
[178, 26, 24],
|
||||
[232, 74, 60],
|
||||
[46, 168, 92],
|
||||
]),
|
||||
})
|
||||
expect(extractCoverTint(image())?.primary.hue).toBeLessThan(20)
|
||||
})
|
||||
|
||||
it('averages hues circularly so colours either side of zero stay red', () => {
|
||||
stubCanvas({
|
||||
data: imageDataFrom([
|
||||
[204, 10, 30],
|
||||
[204, 30, 10],
|
||||
]),
|
||||
})
|
||||
const hue = extractCoverTint(image())!.primary.hue
|
||||
expect(hue > 350 || hue < 10).toBe(true)
|
||||
})
|
||||
|
||||
it('picks up a second tone when the cover carries a distinct accent hue', () => {
|
||||
stubCanvas({
|
||||
data: imageDataFrom([
|
||||
[204, 0, 0],
|
||||
[0, 102, 204],
|
||||
]),
|
||||
})
|
||||
const tint = extractCoverTint(image())
|
||||
expect(tint?.primary.hue).toBe(0)
|
||||
expect(tint?.secondary?.hue).toBe(210)
|
||||
})
|
||||
|
||||
it('ignores a runner-up that is only a neighbouring shade of the first', () => {
|
||||
stubCanvas({
|
||||
data: imageDataFrom([
|
||||
[204, 0, 0],
|
||||
[214, 20, 12],
|
||||
]),
|
||||
})
|
||||
expect(extractCoverTint(image())?.secondary).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a distinct hue that holds too little of the cover', () => {
|
||||
const pixels: Pixel[] = Array.from({ length: 40 }, () => [204, 0, 0] as Pixel)
|
||||
pixels[0] = [0, 102, 204]
|
||||
stubCanvas({ data: imageDataFrom(pixels) })
|
||||
expect(extractCoverTint(image())?.secondary).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a greyscale cover', () => {
|
||||
stubCanvas({ data: imageDataFrom([[128, 128, 128]]) })
|
||||
expect(extractCoverTint(image())).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when a whole cover of near-grey adds up to the weight of a real accent', () => {
|
||||
stubCanvas({ data: imageDataFrom([[130, 128, 126]]) })
|
||||
expect(extractCoverTint(image())).toBeNull()
|
||||
})
|
||||
|
||||
it('still tints a pale cover whose colour survives in only a small share of pixels', () => {
|
||||
// A white field with one accent, the O'Reilly shape: most pixels are dropped
|
||||
// by the lightness band, so the accent carries very little total weight.
|
||||
const pixels: Pixel[] = Array.from({ length: 8 }, () => [252, 252, 250] as Pixel)
|
||||
pixels[0] = [196, 132, 40]
|
||||
stubCanvas({ data: imageDataFrom(pixels) })
|
||||
expect(extractCoverTint(image())?.primary.hue).toBe(35)
|
||||
})
|
||||
|
||||
it('returns null when the image has not decoded', () => {
|
||||
stubCanvas({ data: imageDataFrom([[204, 0, 0]]) })
|
||||
expect(extractCoverTint({ naturalWidth: 0, naturalHeight: 0 } as HTMLImageElement)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the canvas is tainted', () => {
|
||||
stubCanvas(new Error('SecurityError'))
|
||||
expect(extractCoverTint(image())).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('coverTintVars', () => {
|
||||
it('exposes only hue and saturation to CSS', () => {
|
||||
expect(coverTintVars({ primary: { hue: 201, saturation: 70 }, secondary: null })).toEqual({
|
||||
'--cover-tint-hue': '201',
|
||||
'--cover-tint-saturation': '70%',
|
||||
})
|
||||
})
|
||||
|
||||
it('adds the second tone variables when the cover has one', () => {
|
||||
expect(coverTintVars({ primary: { hue: 201, saturation: 70 }, secondary: { hue: 34, saturation: 55 } })).toEqual({
|
||||
'--cover-tint-hue': '201',
|
||||
'--cover-tint-saturation': '70%',
|
||||
'--cover-tint-hue-2': '34',
|
||||
'--cover-tint-saturation-2': '55%',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined when there is no tint', () => {
|
||||
expect(coverTintVars(null)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
export type CoverTintTone = {
|
||||
hue: number
|
||||
saturation: number
|
||||
}
|
||||
|
||||
export type CoverTint = {
|
||||
primary: CoverTintTone
|
||||
// Covers are usually one colour plus an accent; when that accent is a genuinely
|
||||
// different hue the tint carries both, otherwise this stays null and the tint
|
||||
// renders as a single wash.
|
||||
secondary: CoverTintTone | null
|
||||
}
|
||||
|
||||
const SAMPLE_SIZE = 32
|
||||
|
||||
// Near-black and near-white pixels carry no usable hue, and covers are full of
|
||||
// both (text, borders, letterboxing), so they would otherwise win on count alone.
|
||||
const MIN_LIGHTNESS = 0.18
|
||||
const MAX_LIGHTNESS = 0.82
|
||||
|
||||
// The winning bucket sets the hue, but its own saturation is clamped: too low
|
||||
// reads as dirty grey against the card, too high fights the accent colour.
|
||||
const MIN_SATURATION = 0.35
|
||||
const MAX_SATURATION = 0.7
|
||||
|
||||
// Colours are clustered by hue, not by RGB cube. A cover's dominant colour is
|
||||
// spread across many shades (shadow, midtone, highlight) that share a hue but
|
||||
// land in different RGB cells, so cube bucketing splits its vote and lets a
|
||||
// small flat region of some other colour win.
|
||||
const HUE_BINS = 36
|
||||
const BIN_WIDTH = 360 / HUE_BINS
|
||||
|
||||
// Pixels are weighted by squared saturation, so a small vivid area outvotes a
|
||||
// large muddy one. The floor only rejects covers with essentially no colour at
|
||||
// all: pale covers (a white field with one accent) keep very few pixels once the
|
||||
// lightness band is applied, and they still deserve their accent.
|
||||
const MIN_TINT_WEIGHT = 3
|
||||
|
||||
// Weight alone cannot tell a small vivid accent from a whole cover of near-grey,
|
||||
// which sums to the same number. The winning hue also has to be a colour.
|
||||
const MIN_TINT_SATURATION = 0.1
|
||||
|
||||
// A second tone has to be a different colour, not a neighbouring shade of the
|
||||
// first, and has to hold enough of the cover to be deliberate rather than noise.
|
||||
const MIN_SECONDARY_HUE_DISTANCE = 40
|
||||
const MIN_SECONDARY_WEIGHT_RATIO = 0.2
|
||||
|
||||
type HueBin = {
|
||||
weight: number
|
||||
sin: number
|
||||
cos: number
|
||||
saturation: number
|
||||
}
|
||||
|
||||
function toHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
const rn = r / 255
|
||||
const gn = g / 255
|
||||
const bn = b / 255
|
||||
const max = Math.max(rn, gn, bn)
|
||||
const min = Math.min(rn, gn, bn)
|
||||
const lightness = (max + min) / 2
|
||||
const delta = max - min
|
||||
if (delta === 0) return [0, 0, lightness]
|
||||
|
||||
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min)
|
||||
let hue: number
|
||||
if (max === rn) hue = (gn - bn) / delta + (gn < bn ? 6 : 0)
|
||||
else if (max === gn) hue = (bn - rn) / delta + 2
|
||||
else hue = (rn - gn) / delta + 4
|
||||
|
||||
return [hue * 60, saturation, lightness]
|
||||
}
|
||||
|
||||
function hueDistance(a: number, b: number): number {
|
||||
const raw = Math.abs(a - b) % 360
|
||||
return raw > 180 ? 360 - raw : raw
|
||||
}
|
||||
|
||||
function samplePixels(image: HTMLImageElement): Uint8ClampedArray | null {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = SAMPLE_SIZE
|
||||
canvas.height = SAMPLE_SIZE
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!context) return null
|
||||
|
||||
try {
|
||||
context.drawImage(image, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE)
|
||||
return context.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE).data
|
||||
} catch {
|
||||
// A cross-origin cover taints the canvas; no tint is better than a crash.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function collectHueBins(pixels: Uint8ClampedArray): HueBin[] {
|
||||
const bins: HueBin[] = Array.from({ length: HUE_BINS }, () => ({ weight: 0, sin: 0, cos: 0, saturation: 0 }))
|
||||
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
if (pixels[i + 3]! < 128) continue
|
||||
const [hue, saturation, lightness] = toHsl(pixels[i]!, pixels[i + 1]!, pixels[i + 2]!)
|
||||
if (lightness < MIN_LIGHTNESS || lightness > MAX_LIGHTNESS) continue
|
||||
|
||||
const weight = saturation * saturation
|
||||
const bin = bins[Math.min(HUE_BINS - 1, Math.floor(hue / BIN_WIDTH))]!
|
||||
const radians = (hue * Math.PI) / 180
|
||||
bin.weight += weight
|
||||
bin.sin += weight * Math.sin(radians)
|
||||
bin.cos += weight * Math.cos(radians)
|
||||
bin.saturation += weight * saturation
|
||||
}
|
||||
|
||||
return bins
|
||||
}
|
||||
|
||||
// Neighbouring bins are folded in so a hue sitting on a bin boundary is not
|
||||
// split in half, and so the peak reflects a band of colour rather than one slice.
|
||||
function neighbourhood(bins: HueBin[], index: number): HueBin {
|
||||
const merged: HueBin = { weight: 0, sin: 0, cos: 0, saturation: 0 }
|
||||
for (const offset of [-1, 0, 1]) {
|
||||
const bin = bins[(index + offset + HUE_BINS) % HUE_BINS]!
|
||||
merged.weight += bin.weight
|
||||
merged.sin += bin.sin
|
||||
merged.cos += bin.cos
|
||||
merged.saturation += bin.saturation
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function toTone(merged: HueBin): CoverTintTone {
|
||||
// Hues are angles, so the average has to be circular: a cover split between
|
||||
// 350 and 10 degrees is red, not cyan.
|
||||
const hue = ((((Math.atan2(merged.sin, merged.cos) * 180) / Math.PI + 360) % 360) + 0.5) | 0
|
||||
const saturation = merged.saturation / merged.weight
|
||||
return {
|
||||
hue,
|
||||
saturation: Math.round(Math.min(MAX_SATURATION, Math.max(MIN_SATURATION, saturation)) * 100),
|
||||
}
|
||||
}
|
||||
|
||||
export function extractCoverTint(image: HTMLImageElement): CoverTint | null {
|
||||
if (image.naturalWidth <= 0 || image.naturalHeight <= 0) return null
|
||||
|
||||
const pixels = samplePixels(image)
|
||||
if (!pixels) return null
|
||||
|
||||
const bins = collectHueBins(pixels)
|
||||
const ranked = bins.map((_, index) => ({ index, merged: neighbourhood(bins, index) })).sort((a, b) => b.merged.weight - a.merged.weight)
|
||||
|
||||
const best = ranked[0]
|
||||
if (!best || best.merged.weight < MIN_TINT_WEIGHT) return null
|
||||
if (best.merged.saturation / best.merged.weight < MIN_TINT_SATURATION) return null
|
||||
|
||||
const primary = toTone(best.merged)
|
||||
const runnerUp = ranked.find(
|
||||
(candidate) =>
|
||||
candidate.merged.weight >= best.merged.weight * MIN_SECONDARY_WEIGHT_RATIO &&
|
||||
hueDistance(toTone(candidate.merged).hue, primary.hue) >= MIN_SECONDARY_HUE_DISTANCE,
|
||||
)
|
||||
|
||||
return { primary, secondary: runnerUp ? toTone(runnerUp.merged) : null }
|
||||
}
|
||||
|
||||
// Only hue and saturation cross into CSS; the gradient fixes lightness and picks
|
||||
// its own alphas per theme, so one tint composites correctly in light and dark.
|
||||
export function coverTintVars(tint: CoverTint | null): Record<string, string> | undefined {
|
||||
if (!tint) return undefined
|
||||
|
||||
const vars: Record<string, string> = {
|
||||
'--cover-tint-hue': String(tint.primary.hue),
|
||||
'--cover-tint-saturation': `${tint.primary.saturation}%`,
|
||||
}
|
||||
if (tint.secondary) {
|
||||
vars['--cover-tint-hue-2'] = String(tint.secondary.hue)
|
||||
vars['--cover-tint-saturation-2'] = `${tint.secondary.saturation}%`
|
||||
}
|
||||
return vars
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import {
|
||||
useDisplaySettings,
|
||||
type BookCoverDisplayMode,
|
||||
type BookDetailCoverTint,
|
||||
type BookShadowStrength,
|
||||
type BookSpineOverlay,
|
||||
type CardOverlayKey,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { cardOverlays, bookSpineOverlay, showSpineOnComics, bookShadowStrength, bookCoverDisplayMode } = useDisplaySettings()
|
||||
const { cardOverlays, bookSpineOverlay, showSpineOnComics, bookShadowStrength, bookCoverDisplayMode, bookDetailCoverTint } = useDisplaySettings()
|
||||
|
||||
const overlayOptions = computed<{ key: CardOverlayKey; label: string; hint: string }[]>(() => [
|
||||
{
|
||||
@@ -69,6 +70,24 @@ const bookCoverDisplayOptions = computed<{ id: BookCoverDisplayMode; label: stri
|
||||
},
|
||||
])
|
||||
|
||||
const bookDetailCoverTintOptions = computed<{ id: BookDetailCoverTint; label: string; hint: string }[]>(() => [
|
||||
{
|
||||
id: 'off',
|
||||
label: t('settings.appearance.bookCovers.detailCoverTint.off.label'),
|
||||
hint: t('settings.appearance.bookCovers.detailCoverTint.off.hint'),
|
||||
},
|
||||
{
|
||||
id: 'single',
|
||||
label: t('settings.appearance.bookCovers.detailCoverTint.single.label'),
|
||||
hint: t('settings.appearance.bookCovers.detailCoverTint.single.hint'),
|
||||
},
|
||||
{
|
||||
id: 'duotone',
|
||||
label: t('settings.appearance.bookCovers.detailCoverTint.duotone.label'),
|
||||
hint: t('settings.appearance.bookCovers.detailCoverTint.duotone.hint'),
|
||||
},
|
||||
])
|
||||
|
||||
function toggleOverlay(key: CardOverlayKey) {
|
||||
const idx = cardOverlays.value.indexOf(key)
|
||||
if (idx === -1) cardOverlays.value = [...cardOverlays.value, key]
|
||||
@@ -86,6 +105,10 @@ function setBookShadowStrength(mode: BookShadowStrength) {
|
||||
function setBookCoverDisplayMode(mode: BookCoverDisplayMode) {
|
||||
bookCoverDisplayMode.value = mode
|
||||
}
|
||||
|
||||
function setBookDetailCoverTint(mode: BookDetailCoverTint) {
|
||||
bookDetailCoverTint.value = mode
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -148,6 +171,28 @@ function setBookCoverDisplayMode(mode: BookCoverDisplayMode) {
|
||||
</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">{{ t('settings.appearance.bookCovers.detailCoverTint.title') }}</p>
|
||||
<p class="settings-hint">{{ t('settings.appearance.bookCovers.detailCoverTint.hint') }}</p>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="opt in bookDetailCoverTintOptions"
|
||||
:key="opt.id"
|
||||
class="rounded-md border px-3 py-2 text-left transition-colors"
|
||||
:class="
|
||||
bookDetailCoverTint === opt.id
|
||||
? 'border-primary bg-primary/8 text-primary'
|
||||
: 'border-border text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground'
|
||||
"
|
||||
@click="setBookDetailCoverTint(opt.id)"
|
||||
>
|
||||
<p class="text-xs font-semibold">{{ opt.label }}</p>
|
||||
<p class="mt-0.5 text-[11px] leading-snug opacity-80">{{ opt.hint }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
|
||||
<div>
|
||||
<p class="settings-label">{{ t('settings.appearance.bookCovers.shadow.title') }}</p>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import AppearanceBookCoverSettings from '../AppearanceBookCoverSettings.vue'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
|
||||
const { showSpineOnComics } = useDisplaySettings()
|
||||
const { showSpineOnComics, bookDetailCoverTint } = useDisplaySettings()
|
||||
|
||||
const ToggleSwitchStub = {
|
||||
name: 'ToggleSwitch',
|
||||
@@ -22,6 +22,7 @@ function mountSettings() {
|
||||
|
||||
afterEach(() => {
|
||||
showSpineOnComics.value = false
|
||||
bookDetailCoverTint.value = 'single'
|
||||
})
|
||||
|
||||
describe('AppearanceBookCoverSettings', () => {
|
||||
@@ -37,4 +38,22 @@ describe('AppearanceBookCoverSettings', () => {
|
||||
await toggle.trigger('click')
|
||||
expect(showSpineOnComics.value).toBe(true)
|
||||
})
|
||||
|
||||
it('selects a book detail cover tint mode', async () => {
|
||||
const wrapper = mountSettings()
|
||||
|
||||
const section = wrapper.findAll('div').find((d) => d.text().startsWith('Book details cover tint'))
|
||||
const buttons = section!.findAll('button')
|
||||
expect(buttons.map((b) => b.text())).toEqual([
|
||||
'OffPlain card background',
|
||||
"One colourThe cover's dominant colour only",
|
||||
"Two coloursAdds the cover's accent colour when it has a distinct one",
|
||||
])
|
||||
|
||||
await buttons[2]!.trigger('click')
|
||||
expect(bookDetailCoverTint.value).toBe('duotone')
|
||||
|
||||
await buttons[0]!.trigger('click')
|
||||
expect(bookDetailCoverTint.value).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -254,6 +254,22 @@
|
||||
"label": "Show spine on comics",
|
||||
"hint": "Apply the spine and gloss effect to comic covers (cbz, cbr, cb7)"
|
||||
},
|
||||
"detailCoverTint": {
|
||||
"title": "Book details cover tint",
|
||||
"hint": "Fades colour sampled from the cover behind the artwork and title on the book details page",
|
||||
"off": {
|
||||
"label": "Off",
|
||||
"hint": "Plain card background"
|
||||
},
|
||||
"single": {
|
||||
"label": "One colour",
|
||||
"hint": "The cover's dominant colour only"
|
||||
},
|
||||
"duotone": {
|
||||
"label": "Two colours",
|
||||
"hint": "Adds the cover's accent colour when it has a distinct one"
|
||||
}
|
||||
},
|
||||
"shadow": {
|
||||
"title": "Cover shadow strength",
|
||||
"hint": "Controls depth under covers across grid, list, table, and dashboard thumbnails",
|
||||
|
||||
@@ -15,6 +15,9 @@ import { normalizeBookDetailTab } from '@/features/book/lib/book-detail-tabs'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { useCoverTint } from '@/features/book/composables/useCoverTint'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import EntityNotFound from '@/components/EntityNotFound.vue'
|
||||
|
||||
const ReadingLogTab = defineAsyncComponent(() => import('@/features/book/components/detail/tabs/ReadingLogTab.vue'))
|
||||
@@ -49,6 +52,22 @@ const pageTitle = computed(() => {
|
||||
})
|
||||
usePageTitle(pageTitle)
|
||||
|
||||
// Only the details tab shows the artwork the tint is derived from; behind forms
|
||||
// and tables the same colour reads as noise.
|
||||
const { coverUrl } = useCoverVersions()
|
||||
const { bookDetailCoverTint } = useDisplaySettings()
|
||||
const tintSource = computed(() => {
|
||||
const book = detail.value
|
||||
if (bookDetailCoverTint.value === 'off' || tab.value !== 'details' || !book || book.coverSource === null) return null
|
||||
return coverUrl(book.id, 'cover', book.updatedAt ?? book.addedAt)
|
||||
})
|
||||
const { tint } = useCoverTint(tintSource)
|
||||
const coverTint = computed(() => {
|
||||
if (!tint.value) return null
|
||||
if (bookDetailCoverTint.value === 'single') return { ...tint.value, secondary: null }
|
||||
return tint.value
|
||||
})
|
||||
|
||||
const { subscribeLibrary } = useScanProgress()
|
||||
watch(
|
||||
() => detail.value?.libraryId,
|
||||
@@ -98,7 +117,7 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BookDetailLayout :book-id="bookId">
|
||||
<BookDetailLayout :book-id="bookId" :cover-tint="coverTint">
|
||||
<Transition name="content" mode="out-in">
|
||||
<div v-if="detail" key="detail">
|
||||
<DetailsTab v-if="tab === 'details'" :book="detail" @saved="onMetadataSaved" @moved="handleMovedToLibrary" />
|
||||
|
||||
@@ -25,6 +25,9 @@ export type BookShadowStrength = (typeof BOOK_SHADOW_STRENGTHS)[number];
|
||||
export const BOOK_COVER_DISPLAY_MODES = ["blurred-fit", "fill-crop", "natural-bottom"] as const;
|
||||
export type BookCoverDisplayMode = (typeof BOOK_COVER_DISPLAY_MODES)[number];
|
||||
|
||||
export const BOOK_DETAIL_COVER_TINTS = ["off", "single", "duotone"] as const;
|
||||
export type BookDetailCoverTint = (typeof BOOK_DETAIL_COVER_TINTS)[number];
|
||||
|
||||
export const SERIES_CARD_COVER_MODES = ["stack", "mosaic", "first-volume", "latest-volume", "first-unread"] as const;
|
||||
export type SeriesCardCoverMode = (typeof SERIES_CARD_COVER_MODES)[number];
|
||||
|
||||
@@ -53,6 +56,7 @@ export interface DisplayPreferences {
|
||||
showSpineOnComics: boolean;
|
||||
bookShadowStrength: BookShadowStrength;
|
||||
bookCoverDisplayMode: BookCoverDisplayMode;
|
||||
bookDetailCoverTint: BookDetailCoverTint;
|
||||
seriesCardCoverMode: SeriesCardCoverMode;
|
||||
gridCardPrimaryLabel: GridCardLabelField;
|
||||
gridCardSecondaryLabel: GridCardLabelField;
|
||||
|
||||
@@ -6,12 +6,14 @@ import { Logger } from '@nestjs/common';
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
realpath: vi.fn(),
|
||||
}));
|
||||
|
||||
import { stat, readdir } from 'fs/promises';
|
||||
import { stat, readdir, realpath } from 'fs/promises';
|
||||
|
||||
const mockStat = stat as MockedFunction<typeof stat>;
|
||||
const mockReaddir = readdir as MockedFunction<typeof readdir>;
|
||||
const mockRealpath = realpath as MockedFunction<typeof realpath>;
|
||||
|
||||
const mockRepo: Mocked<
|
||||
Pick<
|
||||
@@ -674,6 +676,29 @@ describe('handleCreate — move detection', () => {
|
||||
expect(result).toEqual({ type: 'book-moved', libraryId: 2, bookIds: [30] });
|
||||
});
|
||||
|
||||
it('updates the tracked path for a case-only rename on a case-insensitive filesystem', async () => {
|
||||
const oldPath = '/books/Author/Book.epub';
|
||||
const newPath = '/books/Author/book.epub';
|
||||
const fileStat = makeFileStat({ ino: 4001n, size: 70000 });
|
||||
mockStat.mockResolvedValue(fileStat);
|
||||
mockRealpath.mockResolvedValue(newPath);
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
|
||||
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
|
||||
mockRepo.findBookFileWithContextByIno.mockResolvedValue({
|
||||
file: { id: 51, bookId: 31, absolutePath: oldPath },
|
||||
libraryId: 2,
|
||||
folderPath: '/books/Author',
|
||||
libraryFolderPath: '/books',
|
||||
bookStatus: 'present',
|
||||
} as any);
|
||||
|
||||
const result = await makeService().handleCreate(newPath);
|
||||
|
||||
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(51, expect.objectContaining({ absolutePath: newPath, relPath: 'Author/book.epub' }));
|
||||
expect(mockRepo.markBooksAsPresent).toHaveBeenCalledWith([31]);
|
||||
expect(result).toEqual({ type: 'book-moved', libraryId: 2, bookIds: [31] });
|
||||
});
|
||||
|
||||
it('handles root-level file rename (folderPath equals old absolutePath)', async () => {
|
||||
const fileStat = makeFileStat({ ino: 5000n });
|
||||
// First call: stat for the new path; second call: stat for the old path (should not exist)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { readdir, realpath, stat } from 'fs/promises';
|
||||
import type { BigIntStats } from 'fs';
|
||||
import { dirname, join, relative } from 'path';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
@@ -329,10 +329,14 @@ export class FileEventProcessorService {
|
||||
// Partial move guard - for multi-file books, verify the old file is
|
||||
// actually gone before reassigning. During a folder move, multiple events fire
|
||||
// and some files may not have been unlinked yet.
|
||||
const oldFileStillExists = await stat(file.absolutePath).then(
|
||||
let oldFileStillExists = await stat(file.absolutePath).then(
|
||||
(s) => s.isFile(),
|
||||
() => false,
|
||||
);
|
||||
if (oldFileStillExists && file.absolutePath.toLowerCase() === newAbsolutePath.toLowerCase()) {
|
||||
const canonicalPath = await realpath(file.absolutePath).catch(() => null);
|
||||
oldFileStillExists = canonicalPath !== newAbsolutePath;
|
||||
}
|
||||
if (oldFileStillExists) {
|
||||
this.logger.log(
|
||||
`[scanner.file_event.move] [end] libraryId=${rowLibraryId} bookId=${file.bookId} from="${sanitizeLogValue(file.absolutePath)}" to="${sanitizeLogValue(newAbsolutePath)}" action=skip_old_still_exists - old file still exists, deferring to scan`,
|
||||
|
||||
@@ -70,6 +70,7 @@ const validDisplayPreferences: DisplayPreferences = {
|
||||
showSpineOnComics: false,
|
||||
bookShadowStrength: 'strong',
|
||||
bookCoverDisplayMode: 'natural-bottom',
|
||||
bookDetailCoverTint: 'duotone',
|
||||
seriesCardCoverMode: 'stack',
|
||||
gridCardPrimaryLabel: 'hidden',
|
||||
gridCardSecondaryLabel: 'hidden',
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AUTHOR_COVER_SHAPES,
|
||||
BACKGROUND_IDS,
|
||||
BOOK_COVER_DISPLAY_MODES,
|
||||
BOOK_DETAIL_COVER_TINTS,
|
||||
BOOK_SHADOW_STRENGTHS,
|
||||
BOOK_SPINE_OVERLAYS,
|
||||
BOOK_THUMBNAIL_CLICK_ACTION,
|
||||
@@ -63,6 +64,7 @@ const DISPLAY_PREFERENCES_SCHEMA = z
|
||||
showSpineOnComics: z.boolean().default(false),
|
||||
bookShadowStrength: z.enum(BOOK_SHADOW_STRENGTHS),
|
||||
bookCoverDisplayMode: z.enum(BOOK_COVER_DISPLAY_MODES),
|
||||
bookDetailCoverTint: z.enum(BOOK_DETAIL_COVER_TINTS).default('single'),
|
||||
seriesCardCoverMode: z.enum(SERIES_CARD_COVER_MODES).default('stack'),
|
||||
gridCardPrimaryLabel: z.enum(GRID_CARD_LABEL_FIELDS).default('hidden'),
|
||||
gridCardSecondaryLabel: z.enum(GRID_CARD_LABEL_FIELDS).default('hidden'),
|
||||
|
||||
@@ -250,6 +250,7 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
libraryId: visibleLibrary.libraryId,
|
||||
libraryName: visibleLibraryName,
|
||||
formats: ['epub'],
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
{
|
||||
id: visiblePdf.bookId,
|
||||
@@ -259,6 +260,7 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
libraryId: visibleLibrary.libraryId,
|
||||
libraryName: visibleLibraryName,
|
||||
formats: ['pdf'],
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
{
|
||||
id: visibleCbz.bookId,
|
||||
@@ -268,6 +270,7 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
libraryId: visibleLibrary.libraryId,
|
||||
libraryName: visibleLibraryName,
|
||||
formats: ['cbz'],
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -410,7 +413,14 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
payload: { status: 'reading' },
|
||||
});
|
||||
|
||||
expect(setStatus.statusCode).toBe(204);
|
||||
expect(setStatus.statusCode).toBe(200);
|
||||
expect(setStatus.json()).toMatchObject({
|
||||
status: 'reading',
|
||||
source: 'manual',
|
||||
startedAt: expect.any(String),
|
||||
finishedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
|
||||
const getFileProgress = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
@@ -436,6 +446,11 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
cfi: null,
|
||||
pageNumber: null,
|
||||
percentage: 0,
|
||||
koboLocationSource: null,
|
||||
koboLocationType: null,
|
||||
koboLocationValue: null,
|
||||
koboContentSourceProgressPercent: null,
|
||||
koreaderProgress: null,
|
||||
});
|
||||
|
||||
const limitedBookProgress = await ctx.app.inject({
|
||||
@@ -467,6 +482,11 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
cfi: null,
|
||||
pageNumber: null,
|
||||
percentage: 0,
|
||||
koboLocationSource: null,
|
||||
koboLocationType: null,
|
||||
koboLocationValue: null,
|
||||
koboContentSourceProgressPercent: null,
|
||||
koreaderProgress: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
]);
|
||||
@@ -492,7 +512,7 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
filename: 'alpha-contract.epub',
|
||||
},
|
||||
],
|
||||
collections: [],
|
||||
collections: expect.any(Array),
|
||||
readStatus: {
|
||||
status: 'reading',
|
||||
source: 'manual',
|
||||
@@ -601,7 +621,7 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
|
||||
expect(exportResponse.statusCode).toBe(201);
|
||||
expect(exportResponse.headers['content-type']).toContain('application/zip');
|
||||
expect(exportResponse.headers['content-disposition']).toBe('attachment; filename="books.zip"');
|
||||
expect(exportResponse.headers['content-disposition']).toBe('attachment; filename="books.zip"; filename*=UTF-8\'\'books.zip');
|
||||
|
||||
const zipEntries = await listZipEntries(exportResponse);
|
||||
expect(zipEntries).toEqual(['alpha-contract.epub', 'beta-contract.pdf']);
|
||||
|
||||
@@ -41,6 +41,7 @@ function buildProviderPayload(
|
||||
auth: boolean;
|
||||
ssl: boolean;
|
||||
startTls: boolean;
|
||||
tlsRejectUnauthorized: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
fromName: string;
|
||||
@@ -54,6 +55,7 @@ function buildProviderPayload(
|
||||
auth: overrides.auth ?? false,
|
||||
ssl: overrides.ssl ?? false,
|
||||
startTls: overrides.startTls ?? false,
|
||||
tlsRejectUnauthorized: overrides.tlsRejectUnauthorized ?? true,
|
||||
...(overrides.username !== undefined ? { username: overrides.username } : {}),
|
||||
...(overrides.password !== undefined ? { password: overrides.password } : {}),
|
||||
...(overrides.fromName !== undefined ? { fromName: overrides.fromName } : {}),
|
||||
|
||||
@@ -132,24 +132,32 @@ describe('metadata-lock e2e', { timeout: 30_000 }, () => {
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(response.json()).toMatchObject({
|
||||
subtitle: expected.goodreads.subtitle,
|
||||
description: expected.goodreads.description,
|
||||
publisher: expected.goodreads.publisher,
|
||||
language: expected.goodreads.language,
|
||||
pageCount: expected.goodreads.pageCount,
|
||||
goodreadsId: expected.goodreads.providerId,
|
||||
openLibraryId: expected.openLibrary.providerId,
|
||||
coverUrl: expected.goodreads.coverUrl,
|
||||
audioMetadata: {
|
||||
narrators: expected.goodreads.narrators,
|
||||
durationSeconds: expected.goodreads.durationSeconds,
|
||||
abridged: expected.goodreads.abridged,
|
||||
chapters: expected.goodreads.chapters,
|
||||
metadata: {
|
||||
subtitle: expected.goodreads.subtitle,
|
||||
description: expected.goodreads.description,
|
||||
publisher: expected.goodreads.publisher,
|
||||
language: expected.goodreads.language,
|
||||
pageCount: expected.goodreads.pageCount,
|
||||
goodreadsId: expected.goodreads.providerId,
|
||||
openLibraryId: expected.openLibrary.providerId,
|
||||
coverUrl: expected.goodreads.coverUrl,
|
||||
audioMetadata: {
|
||||
narrators: expected.goodreads.narrators,
|
||||
durationSeconds: expected.goodreads.durationSeconds,
|
||||
abridged: expected.goodreads.abridged,
|
||||
chapters: expected.goodreads.chapters,
|
||||
},
|
||||
comicMetadata: {
|
||||
issueNumber: expected.goodreads.comicMetadata?.issueNumber,
|
||||
volumeName: expected.goodreads.comicMetadata?.volumeName,
|
||||
storyArcs: expected.goodreads.comicMetadata?.storyArcs,
|
||||
},
|
||||
},
|
||||
comicMetadata: {
|
||||
issueNumber: expected.goodreads.comicMetadata?.issueNumber,
|
||||
volumeName: expected.goodreads.comicMetadata?.volumeName,
|
||||
storyArcs: expected.goodreads.comicMetadata?.storyArcs,
|
||||
diagnostics: {
|
||||
reason: null,
|
||||
activeProviders: ['goodreads', 'openLibrary'],
|
||||
candidateProviders: ['goodreads', 'openLibrary'],
|
||||
candidateCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { normalizeMetadataTextKey } from '../src/common/utils/metadata-text-normalize.utils';
|
||||
import { createCoverToken } from '../src/modules/opds/opds-auth.guard';
|
||||
import * as schema from '../src/db/schema';
|
||||
import { createEpubFixture, createFb2Fixture, writeFixtureFile } from './e2e/opds/opds-fixture-builder';
|
||||
@@ -627,7 +628,9 @@ describe('OPDS auth and catalog (e2e)', { timeout: 120_000 }, () => {
|
||||
);
|
||||
expect(mobiDownloadResponse.statusCode).toBe(200);
|
||||
expect(mobiDownloadResponse.headers['content-type']).toContain('application/x-mobipocket-ebook');
|
||||
expect(mobiDownloadResponse.headers['content-disposition']).toMatch(/\.mobi"$/);
|
||||
expect(mobiDownloadResponse.headers['content-disposition']).toBe(
|
||||
'attachment; filename="visible-alpha.mobi"; filename*=UTF-8\'\'visible-alpha.mobi',
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes and downloads raw FB2 acquisition files', async () => {
|
||||
@@ -645,7 +648,7 @@ describe('OPDS auth and catalog (e2e)', { timeout: 120_000 }, () => {
|
||||
expect(downloadResponse.statusCode).toBe(200);
|
||||
expect(downloadResponse.headers['content-type']).toContain('application/x-fictionbook+xml');
|
||||
expect(downloadResponse.headers['content-disposition']).toContain('attachment;');
|
||||
expect(downloadResponse.headers['content-disposition']).toMatch(/\.fb2"$/);
|
||||
expect(downloadResponse.headers['content-disposition']).toBe('attachment; filename="visible-raw.fb2"; filename*=UTF-8\'\'visible-raw.fb2');
|
||||
expect(downloadResponse.body).toBe(rawFb2FixtureContent);
|
||||
});
|
||||
});
|
||||
@@ -679,13 +682,34 @@ describe('OPDS auth and catalog (e2e)', { timeout: 120_000 }, () => {
|
||||
}
|
||||
|
||||
async function seedBookMetadata(bookId: number, title: string, seriesName: string, seriesIndex: number, isbn13?: string): Promise<void> {
|
||||
const values = { bookId, title, seriesName, seriesIndex, ...(isbn13 !== undefined ? { isbn13 } : {}) };
|
||||
const set = { title, seriesName, seriesIndex, ...(isbn13 !== undefined ? { isbn13 } : {}) };
|
||||
const normalizedName = normalizeMetadataTextKey(seriesName);
|
||||
if (!normalizedName) throw new Error(`Series normalization failed for ${seriesName}`);
|
||||
|
||||
const [series] = await ctx.db
|
||||
.insert(schema.bookSeries)
|
||||
.values({ name: seriesName, normalizedName })
|
||||
.onConflictDoUpdate({
|
||||
target: schema.bookSeries.normalizedName,
|
||||
set: { name: seriesName, updatedAt: new Date() },
|
||||
})
|
||||
.returning({ id: schema.bookSeries.id });
|
||||
if (!series) throw new Error(`Series upsert failed for ${seriesName}`);
|
||||
|
||||
const values = { bookId, title, seriesId: series.id, seriesName, seriesIndex, ...(isbn13 !== undefined ? { isbn13 } : {}) };
|
||||
const set = { title, seriesId: series.id, seriesName, seriesIndex, ...(isbn13 !== undefined ? { isbn13 } : {}) };
|
||||
|
||||
await ctx.db.insert(schema.bookMetadata).values(values).onConflictDoUpdate({
|
||||
target: schema.bookMetadata.bookId,
|
||||
set,
|
||||
});
|
||||
|
||||
await ctx.db
|
||||
.insert(schema.bookSeriesMemberships)
|
||||
.values({ bookId, seriesId: series.id, seriesIndex, displayOrder: 0 })
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.bookSeriesMemberships.bookId, schema.bookSeriesMemberships.seriesId],
|
||||
set: { seriesIndex, displayOrder: 0, updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async function linkBookAuthor(bookId: number, authorName: string): Promise<void> {
|
||||
|
||||
@@ -1113,6 +1113,7 @@ async function seedStatefulData(db: ScannerE2EContext['db'], bookId: number, pri
|
||||
{
|
||||
userId: userA,
|
||||
bookFileId: primaryFileId,
|
||||
bookId,
|
||||
sessionId: `session-${suffix}-a`,
|
||||
startedAt: new Date(now.getTime() - 300_000),
|
||||
endedAt: new Date(now.getTime() - 240_000),
|
||||
@@ -1123,6 +1124,7 @@ async function seedStatefulData(db: ScannerE2EContext['db'], bookId: number, pri
|
||||
{
|
||||
userId: userB,
|
||||
bookFileId: primaryFileId,
|
||||
bookId,
|
||||
sessionId: `session-${suffix}-b`,
|
||||
startedAt: new Date(now.getTime() - 180_000),
|
||||
endedAt: new Date(now.getTime() - 120_000),
|
||||
|
||||
@@ -75,7 +75,10 @@ function buildMultipartBody(fileName: string, content: Buffer, contentType: stri
|
||||
return { body: Buffer.concat([preamble, content, closing]), boundary };
|
||||
}
|
||||
|
||||
const onePixelPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+lmL8AAAAASUVORK5CYII=', 'base64');
|
||||
const onePixelPng = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVQImWM4YWT0HwAFHgIsBxDy5AAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => {
|
||||
let ctx!: UsersAdminLifecycleE2EContext;
|
||||
|
||||
Reference in New Issue
Block a user