feat(settings): add adaptive cover display preferences (#169)

Allow users to choose how cover artwork is fitted so mixed aspect ratios render cleanly in cards, lists, carousels, and widgets.

Persist display preferences through shared types and user-preferences endpoints so these settings stay consistent across sessions and devices.

Closes #152
This commit is contained in:
Chris Mansell
2026-05-30 18:57:29 -06:00
committed by GitHub
parent c37f668bed
commit 26b3c8ba18
44 changed files with 2829 additions and 1248 deletions
+43
View File
@@ -31,6 +31,12 @@
box-shadow: var(--book-cover-mini-shadow);
}
.book-cover-surface[data-cover-fit='natural-bottom']:has(.book-cover-artwork-frame--natural),
.book-cover-surface[data-cover-fit='natural-bottom']:has(.book-cover-artwork-placeholder-natural) {
background-color: transparent;
box-shadow: none;
}
.book-cover-surface--interactive {
transition: box-shadow 180ms ease;
}
@@ -39,6 +45,36 @@
box-shadow: var(--book-cover-shadow-hover);
}
.group:hover .book-cover-surface--interactive[data-cover-fit='natural-bottom']:has(.book-cover-artwork-frame--natural),
.group:hover .book-cover-surface--interactive[data-cover-fit='natural-bottom']:has(.book-cover-artwork-placeholder-natural) {
box-shadow: none;
}
.book-cover-artwork-frame {
position: absolute;
border-radius: inherit;
overflow: hidden;
}
.book-cover-artwork-frame--full {
inset: 0;
}
.book-cover-artwork-frame--natural,
.book-cover-artwork-placeholder-natural {
box-shadow: var(--book-cover-shadow);
}
.book-cover-surface--mini .book-cover-artwork-frame--natural,
.book-cover-surface--mini .book-cover-artwork-placeholder-natural {
box-shadow: var(--book-cover-mini-shadow);
}
.group:hover .book-cover-surface--interactive .book-cover-artwork-frame--natural,
.group:hover .book-cover-surface--interactive .book-cover-artwork-placeholder-natural {
box-shadow: var(--book-cover-shadow-hover);
}
.book-cover-spine-layer {
pointer-events: none;
border-radius: inherit;
@@ -89,3 +125,10 @@
.book-cover-surface.book-cover-surface--spine-fitted::after {
opacity: 0;
}
.book-cover-surface[data-cover-fit='natural-bottom']:has(.book-cover-artwork-frame--natural)::before,
.book-cover-surface[data-cover-fit='natural-bottom']:has(.book-cover-artwork-frame--natural)::after,
.book-cover-surface[data-cover-fit='natural-bottom']:has(.book-cover-artwork-placeholder-natural)::before,
.book-cover-surface[data-cover-fit='natural-bottom']:has(.book-cover-artwork-placeholder-natural)::after {
opacity: 0;
}
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from 'vitest'
import { applyDisplayPreferences, getDisplayPreferencesSnapshot, sanitizeDisplayPreferences, useDisplaySettings } from '../useDisplaySettings'
const settings = useDisplaySettings()
function resetDisplaySettings() {
settings.portraitCoverSize.value = 130
settings.squareCoverSize.value = 150
settings.coverSizeScope.value = 'per-view'
settings.gridGap.value = 28
settings.portraitGridGap.value = 28
settings.squareGridGap.value = 28
settings.viewMode.value = 'grid'
settings.cardOverlays.value = ['progress-bar', 'format', 'rating', 'read-status', 'series-position']
settings.smartScopeFilterExpanded.value = true
settings.authorCoverSize.value = 120
settings.authorCoverShape.value = 'circle'
settings.tableZebraStriping.value = false
settings.tableDensity.value = 'comfortable'
settings.bookSpineOverlay.value = 'off'
settings.bookShadowStrength.value = 'default'
settings.bookCoverDisplayMode.value = 'blurred-fit'
}
afterEach(() => {
resetDisplaySettings()
})
describe('useDisplaySettings preferences helpers', () => {
it('returns a complete normalized display preferences snapshot', () => {
settings.bookCoverDisplayMode.value = 'natural-bottom'
settings.bookSpineOverlay.value = 'strong'
settings.cardOverlays.value = ['format', 'format', 'provider' as never, 'rating']
expect(getDisplayPreferencesSnapshot()).toMatchObject({
portraitCoverSize: 130,
squareCoverSize: 150,
coverSizeScope: 'per-view',
viewMode: 'grid',
cardOverlays: ['format', 'rating'],
bookSpineOverlay: 'strong',
bookCoverDisplayMode: 'natural-bottom',
})
})
it('sanitizes valid incoming preferences and drops invalid fields', () => {
const sanitized = sanitizeDisplayPreferences({
portraitCoverSize: 999,
squareCoverSize: 'large',
coverSizeScope: 'synced',
cardOverlays: ['format', 'unknown', 'format', 'lock-status'],
bookCoverDisplayMode: 'fill-crop',
tableDensity: 'huge',
extra: true,
})
expect(sanitized).toEqual({
portraitCoverSize: 400,
coverSizeScope: 'synced',
cardOverlays: ['format', 'lock-status'],
bookCoverDisplayMode: 'fill-crop',
})
})
it('applies only sanitized fields to the singleton display settings', () => {
applyDisplayPreferences({
portraitCoverSize: 50,
gridGap: 120,
viewMode: 'table',
authorCoverShape: 'square',
tableZebraStriping: true,
bookShadowStrength: 'strong',
bookCoverDisplayMode: 'natural-bottom',
tableDensity: 'invalid',
})
expect(settings.portraitCoverSize.value).toBe(100)
expect(settings.gridGap.value).toBe(80)
expect(settings.viewMode.value).toBe('table')
expect(settings.authorCoverShape.value).toBe('square')
expect(settings.tableZebraStriping.value).toBe(true)
expect(settings.tableDensity.value).toBe('comfortable')
expect(settings.bookShadowStrength.value).toBe('strong')
expect(settings.bookCoverDisplayMode.value).toBe('natural-bottom')
})
it('ignores non-object payloads', () => {
expect(sanitizeDisplayPreferences(null)).toEqual({})
expect(sanitizeDisplayPreferences('bad')).toEqual({})
})
})
@@ -0,0 +1,224 @@
import type { DisplayPreferences } from '@bookorbit/types'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
function validDisplayPreferences(overrides: Partial<DisplayPreferences> = {}): DisplayPreferences {
return {
portraitCoverSize: 180,
squareCoverSize: 160,
coverSizeScope: 'per-view',
gridGap: 28,
portraitGridGap: 24,
squareGridGap: 20,
viewMode: 'grid',
cardOverlays: ['progress-bar', 'format', 'rating'],
smartScopeFilterExpanded: true,
authorCoverSize: 140,
authorCoverShape: 'circle',
tableZebraStriping: false,
tableDensity: 'comfortable',
bookSpineOverlay: 'subtle',
bookShadowStrength: 'strong',
bookCoverDisplayMode: 'blurred-fit',
...overrides,
}
}
async function loadModules(options: { syncEnabled?: boolean; token?: string | null } = {}) {
vi.resetModules()
localStorage.clear()
const apiMock = vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>>()
const toastErrorMock = vi.fn<() => void>()
const authUser = ref({
settings: { syncThemePreferences: options.syncEnabled ?? true },
})
vi.doMock('@/features/auth/composables/useAuth', () => ({
useAuth: () => ({ user: authUser }),
}))
vi.doMock('@/lib/api', () => ({
api: (...args: unknown[]) => apiMock(...args),
getAccessToken: () => ('token' in options ? options.token : 'access-token'),
}))
vi.doMock('vue-sonner', () => ({
toast: { error: toastErrorMock },
}))
const displaySettings = await import('../useDisplaySettings')
const sync = await import('../useDisplaySettingsSync')
return { apiMock, authUser, displaySettings, sync, toastErrorMock }
}
afterEach(() => {
vi.useRealTimers()
vi.resetModules()
vi.unstubAllGlobals()
})
describe('useDisplaySettingsSync', () => {
it('loads display settings from the server and applies sanitized values', async () => {
const { apiMock, displaySettings, sync } = await loadModules()
apiMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ settings: validDisplayPreferences({ bookCoverDisplayMode: 'natural-bottom' }) }),
})
await sync.loadDisplaySettingsFromServer()
expect(apiMock).toHaveBeenCalledWith('/api/v1/user-preferences/display')
expect(displaySettings.useDisplaySettings().bookCoverDisplayMode.value).toBe('natural-bottom')
})
it('ignores missing or non-ok server display settings responses', async () => {
const { apiMock, displaySettings, sync } = await loadModules()
apiMock.mockResolvedValueOnce({ ok: false }).mockResolvedValueOnce({ ok: true, json: async () => ({ settings: null }) })
await sync.loadDisplaySettingsFromServer()
await sync.loadDisplaySettingsFromServer()
expect(displaySettings.useDisplaySettings().bookCoverDisplayMode.value).toBe('blurred-fit')
})
it('seeds display settings to the server with the expected payload shape', async () => {
const { apiMock, sync } = await loadModules()
apiMock.mockResolvedValueOnce({ ok: true })
const prefs = validDisplayPreferences({ bookCoverDisplayMode: 'fill-crop' })
await sync.seedDisplaySettingsToServer(prefs)
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/user-preferences/display',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ settings: prefs }),
}),
)
})
it('does not save when account preference sync is disabled', async () => {
const { apiMock, sync } = await loadModules({ syncEnabled: false })
await sync.saveDisplaySettingsToServer(validDisplayPreferences())
expect(apiMock).not.toHaveBeenCalled()
})
it('shows a toast when a display settings save receives a non-ok response', async () => {
const { apiMock, sync, toastErrorMock } = await loadModules()
apiMock.mockResolvedValueOnce({ ok: false })
await sync.saveDisplaySettingsToServer(validDisplayPreferences())
expect(toastErrorMock).toHaveBeenCalledWith('Failed to save display preferences')
})
it('shows a toast when a display settings save throws', async () => {
const { apiMock, sync, toastErrorMock } = await loadModules()
apiMock.mockRejectedValueOnce(new Error('network down'))
await sync.saveDisplaySettingsToServer(validDisplayPreferences())
expect(toastErrorMock).toHaveBeenCalledWith('Failed to save display preferences')
})
it('debounces display setting changes before saving to the server', async () => {
vi.useFakeTimers()
const { apiMock, displaySettings, sync } = await loadModules()
apiMock.mockResolvedValue({ ok: true })
sync.initDisplaySettingsSync()
displaySettings.useDisplaySettings().bookCoverDisplayMode.value = 'fill-crop'
await vi.advanceTimersByTimeAsync(1499)
expect(apiMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/user-preferences/display',
expect.objectContaining({
method: 'PUT',
body: expect.stringContaining('"bookCoverDisplayMode":"fill-crop"'),
}),
)
})
it('cancels a pending debounced save', async () => {
vi.useFakeTimers()
const { apiMock, displaySettings, sync } = await loadModules()
sync.initDisplaySettingsSync()
displaySettings.useDisplaySettings().bookCoverDisplayMode.value = 'natural-bottom'
sync.cancelPendingDisplaySettingsSync()
await vi.advanceTimersByTimeAsync(1500)
expect(apiMock).not.toHaveBeenCalled()
})
it('does not queue watcher saves while sync is disabled', async () => {
vi.useFakeTimers()
const { apiMock, displaySettings, sync } = await loadModules({ syncEnabled: false })
sync.initDisplaySettingsSync()
displaySettings.useDisplaySettings().bookCoverDisplayMode.value = 'fill-crop'
await vi.advanceTimersByTimeAsync(1500)
expect(apiMock).not.toHaveBeenCalled()
})
it('coalesces multiple display setting changes into one pending save', async () => {
vi.useFakeTimers()
const { apiMock, displaySettings, sync } = await loadModules()
apiMock.mockResolvedValue({ ok: true })
sync.initDisplaySettingsSync()
displaySettings.useDisplaySettings().bookCoverDisplayMode.value = 'fill-crop'
displaySettings.useDisplaySettings().bookShadowStrength.value = 'strong'
await vi.advanceTimersByTimeAsync(1500)
expect(apiMock).toHaveBeenCalledTimes(1)
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/user-preferences/display',
expect.objectContaining({
body: expect.stringContaining('"bookShadowStrength":"strong"'),
}),
)
})
it('flushes a pending save with keepalive on pagehide', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn<() => Promise<Response>>().mockResolvedValue({ ok: true } as Response)
vi.stubGlobal('fetch', fetchMock)
const { apiMock, displaySettings, sync } = await loadModules({ token: 'pagehide-token' })
sync.initDisplaySettingsSync()
displaySettings.useDisplaySettings().bookCoverDisplayMode.value = 'natural-bottom'
window.dispatchEvent(new Event('pagehide'))
expect(fetchMock).toHaveBeenCalledWith(
'/api/v1/user-preferences/display',
expect.objectContaining({
method: 'PUT',
keepalive: true,
headers: expect.objectContaining({ Authorization: 'Bearer pagehide-token' }),
body: expect.stringContaining('"bookCoverDisplayMode":"natural-bottom"'),
}),
)
await vi.advanceTimersByTimeAsync(1500)
expect(apiMock).not.toHaveBeenCalled()
})
it('does not flush pagehide saves when no access token is available', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn<() => Promise<Response>>().mockResolvedValue({ ok: true } as Response)
vi.stubGlobal('fetch', fetchMock)
const { displaySettings, sync } = await loadModules({ token: null })
sync.initDisplaySettingsSync()
displaySettings.useDisplaySettings().bookCoverDisplayMode.value = 'natural-bottom'
window.dispatchEvent(new Event('pagehide'))
expect(fetchMock).not.toHaveBeenCalled()
})
})
+147 -26
View File
@@ -1,22 +1,44 @@
import { ref, watch } from 'vue'
import {
AUTHOR_COVER_SHAPES,
BOOK_COVER_DISPLAY_MODES,
BOOK_SHADOW_STRENGTHS,
BOOK_SPINE_OVERLAYS,
BOOK_VIEW_MODES,
CARD_OVERLAY_KEYS,
COVER_SIZE_SCOPES,
TABLE_DENSITIES,
type AuthorCoverShape,
type BookCoverDisplayMode,
type BookShadowStrength,
type BookSpineOverlay,
type BookViewMode,
type CardOverlayKey,
type CoverSizeScope,
type DisplayPreferences,
type TableDensity,
} from '@bookorbit/types'
import { storage } from '@/services/storage'
export type CardOverlayKey = 'progress-bar' | 'format' | 'rating' | 'read-status' | 'lock-status' | 'series-position'
export type AuthorCoverShape = 'square' | 'circle'
export type CoverSizeScope = 'per-view' | 'synced'
export type TableDensity = 'compact' | 'comfortable' | 'roomy'
export type BookSpineOverlay = 'off' | 'subtle' | 'strong'
export type BookShadowStrength = 'default' | 'strong'
export type {
AuthorCoverShape,
BookCoverDisplayMode,
BookShadowStrength,
BookSpineOverlay,
BookViewMode,
CardOverlayKey,
CoverSizeScope,
DisplayPreferences,
TableDensity,
} from '@bookorbit/types'
const DEFAULT_PORTRAIT_COVER_SIZE = 130
const DEFAULT_SQUARE_COVER_SIZE = 150
const DEFAULT_GRID_GAP = 28
const DEFAULT_BOOK_SPINE_OVERLAY: BookSpineOverlay = 'off'
const DEFAULT_BOOK_SHADOW_STRENGTH: BookShadowStrength = 'default'
const CARD_OVERLAY_KEYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'lock-status', 'series-position']
const DEFAULT_BOOK_COVER_DISPLAY_MODE: BookCoverDisplayMode = 'blurred-fit'
const DEFAULT_CARD_OVERLAYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'series-position']
const BOOK_SPINE_OVERLAYS: BookSpineOverlay[] = ['off', 'subtle', 'strong']
const BOOK_SHADOW_STRENGTHS: BookShadowStrength[] = ['default', 'strong']
function normalizeBookSpineOverlay(value: unknown): BookSpineOverlay {
return typeof value === 'string' && BOOK_SPINE_OVERLAYS.includes(value as BookSpineOverlay)
@@ -30,6 +52,30 @@ function normalizeBookShadowStrength(value: unknown): BookShadowStrength {
: DEFAULT_BOOK_SHADOW_STRENGTH
}
function normalizeBookCoverDisplayMode(value: unknown): BookCoverDisplayMode {
return typeof value === 'string' && BOOK_COVER_DISPLAY_MODES.includes(value as BookCoverDisplayMode)
? (value as BookCoverDisplayMode)
: DEFAULT_BOOK_COVER_DISPLAY_MODE
}
function normalizeCoverSizeScope(value: unknown): CoverSizeScope {
return typeof value === 'string' && COVER_SIZE_SCOPES.includes(value as CoverSizeScope) ? (value as CoverSizeScope) : 'per-view'
}
function normalizeAuthorCoverShape(value: unknown): AuthorCoverShape {
return typeof value === 'string' && AUTHOR_COVER_SHAPES.includes(value as AuthorCoverShape) ? (value as AuthorCoverShape) : 'circle'
}
function normalizeTableDensity(value: unknown): TableDensity {
return typeof value === 'string' && TABLE_DENSITIES.includes(value as TableDensity) ? (value as TableDensity) : 'comfortable'
}
function normalizePositiveInteger(value: unknown, fallback: number, min: number, max: number): number {
const n = Number(value)
if (!Number.isFinite(n)) return fallback
return Math.min(max, Math.max(min, Math.round(n)))
}
function normalizeCardOverlays(value: unknown): CardOverlayKey[] {
if (!Array.isArray(value)) return [...DEFAULT_CARD_OVERLAYS]
@@ -44,30 +90,32 @@ function normalizeCardOverlays(value: unknown): CardOverlayKey[] {
return normalized
}
const portraitCoverSize = ref(Math.max(storage.get('portraitCoverSize', DEFAULT_PORTRAIT_COVER_SIZE), 100))
const squareCoverSize = ref(Math.max(storage.get('squareCoverSize', DEFAULT_SQUARE_COVER_SIZE), 100))
const coverSizeScope = ref<CoverSizeScope>(storage.get('coverSizeScope', 'per-view'))
const gridGap = ref(storage.get('gridGap', DEFAULT_GRID_GAP))
const portraitGridGap = ref(storage.get('portraitGridGap', gridGap.value))
const squareGridGap = ref(storage.get('squareGridGap', gridGap.value))
export type BookViewMode = 'grid' | 'list' | 'table'
const VALID_VIEW_MODES: BookViewMode[] = ['grid', 'list', 'table']
const portraitCoverSize = ref(
normalizePositiveInteger(storage.get('portraitCoverSize', DEFAULT_PORTRAIT_COVER_SIZE), DEFAULT_PORTRAIT_COVER_SIZE, 100, 400),
)
const squareCoverSize = ref(normalizePositiveInteger(storage.get('squareCoverSize', DEFAULT_SQUARE_COVER_SIZE), DEFAULT_SQUARE_COVER_SIZE, 100, 400))
const coverSizeScope = ref<CoverSizeScope>(normalizeCoverSizeScope(storage.get('coverSizeScope', 'per-view')))
const gridGap = ref(normalizePositiveInteger(storage.get('gridGap', DEFAULT_GRID_GAP), DEFAULT_GRID_GAP, 1, 80))
const portraitGridGap = ref(normalizePositiveInteger(storage.get('portraitGridGap', gridGap.value), gridGap.value, 1, 80))
const squareGridGap = ref(normalizePositiveInteger(storage.get('squareGridGap', gridGap.value), gridGap.value, 1, 80))
function normalizeViewMode(v: unknown): BookViewMode {
if (typeof v === 'string' && VALID_VIEW_MODES.includes(v as BookViewMode)) return v as BookViewMode
if (typeof v === 'string' && BOOK_VIEW_MODES.includes(v as BookViewMode)) return v as BookViewMode
return 'grid'
}
const viewMode = ref<BookViewMode>(normalizeViewMode(storage.get('viewMode', 'grid')))
const cardOverlays = ref<CardOverlayKey[]>(normalizeCardOverlays(storage.get('cardOverlays', DEFAULT_CARD_OVERLAYS)))
const smartScopeFilterExpanded = ref(storage.get('smartScopeFilterExpanded', true))
const authorCoverSize = ref(Math.max(storage.get('authorCoverSize', 120), 100))
const authorCoverShape = ref<AuthorCoverShape>(storage.get('authorCoverShape', 'circle'))
const tableZebraStriping = ref(storage.get('tableZebraStriping', false))
const tableDensity = ref<TableDensity>(storage.get('tableDensity', 'comfortable'))
const smartScopeFilterExpanded = ref(storage.get<boolean>('smartScopeFilterExpanded', true) === true)
const authorCoverSize = ref(normalizePositiveInteger(storage.get('authorCoverSize', 120), 120, 100, 400))
const authorCoverShape = ref<AuthorCoverShape>(normalizeAuthorCoverShape(storage.get('authorCoverShape', 'circle')))
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 bookShadowStrength = ref<BookShadowStrength>(normalizeBookShadowStrength(storage.get('bookShadowStrength', DEFAULT_BOOK_SHADOW_STRENGTH)))
const bookCoverDisplayMode = ref<BookCoverDisplayMode>(
normalizeBookCoverDisplayMode(storage.get('bookCoverDisplayMode', DEFAULT_BOOK_COVER_DISPLAY_MODE)),
)
watch(portraitCoverSize, (v) => storage.set('portraitCoverSize', v))
watch(squareCoverSize, (v) => storage.set('squareCoverSize', v))
@@ -79,11 +127,83 @@ watch(viewMode, (v) => storage.set('viewMode', v))
watch(cardOverlays, (v) => storage.set('cardOverlays', normalizeCardOverlays(v)), { deep: true })
watch(smartScopeFilterExpanded, (v) => storage.set('smartScopeFilterExpanded', v))
watch(authorCoverSize, (v) => storage.set('authorCoverSize', v))
watch(authorCoverShape, (v) => storage.set('authorCoverShape', v))
watch(authorCoverShape, (v) => storage.set('authorCoverShape', normalizeAuthorCoverShape(v)))
watch(tableZebraStriping, (v) => storage.set('tableZebraStriping', v))
watch(tableDensity, (v) => storage.set('tableDensity', v))
watch(tableDensity, (v) => storage.set('tableDensity', normalizeTableDensity(v)))
watch(bookSpineOverlay, (value) => storage.set('bookSpineOverlay', normalizeBookSpineOverlay(value)))
watch(bookShadowStrength, (value) => storage.set('bookShadowStrength', normalizeBookShadowStrength(value)))
watch(bookCoverDisplayMode, (value) => storage.set('bookCoverDisplayMode', normalizeBookCoverDisplayMode(value)))
export function getDisplayPreferencesSnapshot(): DisplayPreferences {
return {
portraitCoverSize: normalizePositiveInteger(portraitCoverSize.value, DEFAULT_PORTRAIT_COVER_SIZE, 100, 400),
squareCoverSize: normalizePositiveInteger(squareCoverSize.value, DEFAULT_SQUARE_COVER_SIZE, 100, 400),
coverSizeScope: normalizeCoverSizeScope(coverSizeScope.value),
gridGap: normalizePositiveInteger(gridGap.value, DEFAULT_GRID_GAP, 1, 80),
portraitGridGap: normalizePositiveInteger(portraitGridGap.value, DEFAULT_GRID_GAP, 1, 80),
squareGridGap: normalizePositiveInteger(squareGridGap.value, DEFAULT_GRID_GAP, 1, 80),
viewMode: normalizeViewMode(viewMode.value),
cardOverlays: normalizeCardOverlays(cardOverlays.value),
smartScopeFilterExpanded: smartScopeFilterExpanded.value === true,
authorCoverSize: normalizePositiveInteger(authorCoverSize.value, 120, 100, 400),
authorCoverShape: normalizeAuthorCoverShape(authorCoverShape.value),
tableZebraStriping: tableZebraStriping.value === true,
tableDensity: normalizeTableDensity(tableDensity.value),
bookSpineOverlay: normalizeBookSpineOverlay(bookSpineOverlay.value),
bookShadowStrength: normalizeBookShadowStrength(bookShadowStrength.value),
bookCoverDisplayMode: normalizeBookCoverDisplayMode(bookCoverDisplayMode.value),
}
}
export function sanitizeDisplayPreferences(raw: unknown): Partial<DisplayPreferences> {
if (typeof raw !== 'object' || raw === null) return {}
const obj = raw as Record<string, unknown>
const out: Partial<DisplayPreferences> = {}
if (typeof obj.portraitCoverSize === 'number')
out.portraitCoverSize = normalizePositiveInteger(obj.portraitCoverSize, DEFAULT_PORTRAIT_COVER_SIZE, 100, 400)
if (typeof obj.squareCoverSize === 'number')
out.squareCoverSize = normalizePositiveInteger(obj.squareCoverSize, DEFAULT_SQUARE_COVER_SIZE, 100, 400)
if (COVER_SIZE_SCOPES.includes(obj.coverSizeScope as CoverSizeScope)) out.coverSizeScope = obj.coverSizeScope as CoverSizeScope
if (typeof obj.gridGap === 'number') out.gridGap = normalizePositiveInteger(obj.gridGap, DEFAULT_GRID_GAP, 1, 80)
if (typeof obj.portraitGridGap === 'number') out.portraitGridGap = normalizePositiveInteger(obj.portraitGridGap, DEFAULT_GRID_GAP, 1, 80)
if (typeof obj.squareGridGap === 'number') out.squareGridGap = normalizePositiveInteger(obj.squareGridGap, DEFAULT_GRID_GAP, 1, 80)
if (BOOK_VIEW_MODES.includes(obj.viewMode as BookViewMode)) out.viewMode = obj.viewMode as BookViewMode
if (Array.isArray(obj.cardOverlays)) out.cardOverlays = normalizeCardOverlays(obj.cardOverlays)
if (typeof obj.smartScopeFilterExpanded === 'boolean') out.smartScopeFilterExpanded = obj.smartScopeFilterExpanded
if (typeof obj.authorCoverSize === 'number') out.authorCoverSize = normalizePositiveInteger(obj.authorCoverSize, 120, 100, 400)
if (AUTHOR_COVER_SHAPES.includes(obj.authorCoverShape as AuthorCoverShape)) out.authorCoverShape = obj.authorCoverShape as AuthorCoverShape
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 (BOOK_SHADOW_STRENGTHS.includes(obj.bookShadowStrength as BookShadowStrength))
out.bookShadowStrength = obj.bookShadowStrength as BookShadowStrength
if (BOOK_COVER_DISPLAY_MODES.includes(obj.bookCoverDisplayMode as BookCoverDisplayMode)) {
out.bookCoverDisplayMode = obj.bookCoverDisplayMode as BookCoverDisplayMode
}
return out
}
export function applyDisplayPreferences(raw: unknown): void {
const prefs = sanitizeDisplayPreferences(raw)
if (prefs.portraitCoverSize !== undefined) portraitCoverSize.value = prefs.portraitCoverSize
if (prefs.squareCoverSize !== undefined) squareCoverSize.value = prefs.squareCoverSize
if (prefs.coverSizeScope !== undefined) coverSizeScope.value = prefs.coverSizeScope
if (prefs.gridGap !== undefined) gridGap.value = prefs.gridGap
if (prefs.portraitGridGap !== undefined) portraitGridGap.value = prefs.portraitGridGap
if (prefs.squareGridGap !== undefined) squareGridGap.value = prefs.squareGridGap
if (prefs.viewMode !== undefined) viewMode.value = prefs.viewMode
if (prefs.cardOverlays !== undefined) cardOverlays.value = prefs.cardOverlays
if (prefs.smartScopeFilterExpanded !== undefined) smartScopeFilterExpanded.value = prefs.smartScopeFilterExpanded
if (prefs.authorCoverSize !== undefined) authorCoverSize.value = prefs.authorCoverSize
if (prefs.authorCoverShape !== undefined) authorCoverShape.value = prefs.authorCoverShape
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.bookShadowStrength !== undefined) bookShadowStrength.value = prefs.bookShadowStrength
if (prefs.bookCoverDisplayMode !== undefined) bookCoverDisplayMode.value = prefs.bookCoverDisplayMode
}
export function useDisplaySettings() {
return {
@@ -102,5 +222,6 @@ export function useDisplaySettings() {
tableDensity,
bookSpineOverlay,
bookShadowStrength,
bookCoverDisplayMode,
}
}
@@ -0,0 +1,137 @@
import type { DisplayPreferences } from '@bookorbit/types'
import { watch } from 'vue'
import { toast } from 'vue-sonner'
import { useAuth } from '@/features/auth/composables/useAuth'
import { api, getAccessToken } from '@/lib/api'
import { applyDisplayPreferences, getDisplayPreferencesSnapshot, useDisplaySettings } from '@/composables/useDisplaySettings'
let initialized = false
let isApplyingServerPrefs = false
let pendingSave: ReturnType<typeof setTimeout> | null = null
let pagehideRegistered = false
function isSyncEnabled(): boolean {
const { user } = useAuth()
return user.value?.settings?.syncThemePreferences === true
}
function flushPendingSave(): void {
if (pendingSave === null || !isSyncEnabled()) return
clearTimeout(pendingSave)
pendingSave = null
const accessToken = getAccessToken()
if (!accessToken) return
void fetch('/api/v1/user-preferences/display', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
credentials: 'include',
keepalive: true,
body: JSON.stringify({ settings: getDisplayPreferencesSnapshot() }),
})
}
export async function loadDisplaySettingsFromServer(): Promise<void> {
try {
const res = await api('/api/v1/user-preferences/display')
if (!res.ok) return
const body = (await res.json()) as { settings: unknown }
if (body.settings === null || body.settings === undefined) return
isApplyingServerPrefs = true
try {
applyDisplayPreferences(body.settings)
} finally {
isApplyingServerPrefs = false
}
} catch {
// Silent on startup.
}
}
export async function seedDisplaySettingsToServer(prefs: DisplayPreferences): Promise<void> {
try {
await api('/api/v1/user-preferences/display', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: prefs }),
})
} catch {
// Silent seed failure.
}
}
export async function saveDisplaySettingsToServer(prefs: DisplayPreferences): Promise<void> {
if (!isSyncEnabled()) return
try {
const res = await api('/api/v1/user-preferences/display', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: prefs }),
})
if (!res.ok) {
toast.error('Failed to save display preferences')
}
} catch {
toast.error('Failed to save display preferences')
}
}
export function cancelPendingDisplaySettingsSync(): void {
if (pendingSave !== null) {
clearTimeout(pendingSave)
pendingSave = null
}
}
export function initDisplaySettingsSync(): void {
if (initialized) return
initialized = true
const settings = useDisplaySettings()
watch(
() =>
[
settings.portraitCoverSize.value,
settings.squareCoverSize.value,
settings.coverSizeScope.value,
settings.gridGap.value,
settings.portraitGridGap.value,
settings.squareGridGap.value,
settings.viewMode.value,
settings.cardOverlays.value.join('\u0000'),
settings.smartScopeFilterExpanded.value,
settings.authorCoverSize.value,
settings.authorCoverShape.value,
settings.tableZebraStriping.value,
settings.tableDensity.value,
settings.bookSpineOverlay.value,
settings.bookShadowStrength.value,
settings.bookCoverDisplayMode.value,
] as const,
() => {
if (isApplyingServerPrefs || !isSyncEnabled()) return
if (pendingSave !== null) clearTimeout(pendingSave)
pendingSave = setTimeout(() => {
pendingSave = null
void saveDisplaySettingsToServer(getDisplayPreferencesSnapshot())
}, 1500)
},
{ flush: 'sync' },
)
if (!pagehideRegistered && typeof window !== 'undefined') {
pagehideRegistered = true
window.addEventListener('pagehide', flushPendingSave)
}
}
@@ -2,6 +2,7 @@ import { ref } from 'vue'
import type { AuthUser, AuthResponse } from '@bookorbit/types'
import { api, refreshAccessToken, setAccessToken, setOnAuthFailure } from '@/lib/api'
import router from '@/router'
import { cancelPendingDisplaySettingsSync, initDisplaySettingsSync, loadDisplaySettingsFromServer } from '@/composables/useDisplaySettingsSync'
import { cancelPendingThemeSync, initThemeSync, loadFromServer } from '@/composables/useThemeSync'
import { useSetupStatus } from './useSetupStatus'
import { disconnectAuthorEnrichmentSocket } from '@/features/settings/composables/useAuthorEnrichmentStatus'
@@ -45,6 +46,7 @@ if (typeof document !== 'undefined') {
function clearAuth() {
cancelPendingThemeSync()
cancelPendingDisplaySettingsSync()
stopSessionRefresh()
user.value = null
setAccessToken(null)
@@ -71,9 +73,11 @@ async function hydrateThemeSync(options: { refreshUser?: boolean } = {}): Promis
if (user.value?.settings?.syncThemePreferences) {
await loadFromServer()
await loadDisplaySettingsFromServer()
}
initThemeSync()
initDisplaySettingsSync()
}
export function useAuth() {
@@ -88,6 +92,7 @@ export function useAuth() {
// no valid session
} finally {
initThemeSync()
initDisplaySettingsSync()
isLoading.value = false
}
}
@@ -0,0 +1,157 @@
<script setup lang="ts">
import type { BookCoverDisplayMode, CoverAspectRatio } from '@bookorbit/types'
import { computed, inject, ref, watch, type ComponentPublicInstance } from 'vue'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO, coverAspectRatioValue, fittedCoverFrameStyle } from '../lib/cover-aspect-ratio'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
const props = withDefaults(
defineProps<{
src: string | null
hasCover: boolean
title: string | null
authorLine?: string | null
isAudio?: boolean
seed: string
alt?: string
mode?: BookCoverDisplayMode
frameAspectRatio?: CoverAspectRatio | string
loading?: 'lazy' | 'eager'
decoding?: 'async' | 'auto' | 'sync'
backdropClass?: string
imageClass?: string
resetKey?: string | number | null
spine?: boolean
}>(),
{
authorLine: null,
isAudio: false,
alt: '',
mode: undefined,
frameAspectRatio: undefined,
loading: 'lazy',
decoding: 'async',
backdropClass: 'blur-md brightness-90',
imageClass: '',
resetKey: null,
spine: true,
},
)
const emit = defineEmits<{
load: [ratio: number | null]
error: []
}>()
const { bookCoverDisplayMode } = useDisplaySettings()
const injectedAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const loaded = ref(false)
const failed = ref(false)
const imageRatio = ref<number | null>(null)
const effectiveMode = computed(() => props.mode ?? bookCoverDisplayMode.value)
const frameAspectRatio = computed(() => props.frameAspectRatio ?? injectedAspectRatio.value)
const slotRatio = computed(() => coverAspectRatioValue(String(frameAspectRatio.value)))
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 showSpineLayer = computed(() => props.spine && canRenderImage.value && loaded.value)
const frameStyle = computed(() => {
if (useNaturalFrame.value) return fittedCoverFrameStyle(imageRatio.value, slotRatio.value, 'bottom')
return { inset: '0' }
})
const imageFitClass = computed(() => {
if (effectiveMode.value === 'blurred-fit') return 'object-contain'
return 'object-cover'
})
const spineStyle = computed(() => {
if (effectiveMode.value === 'blurred-fit') return fittedCoverFrameStyle(imageRatio.value, slotRatio.value)
return { inset: '0' }
})
function resetImageState() {
loaded.value = false
failed.value = false
imageRatio.value = null
}
watch(() => [props.src, props.hasCover, props.resetKey] as const, resetImageState)
function updateImageRatio(img: HTMLImageElement | null): number | null {
if (!img || img.naturalWidth <= 0 || img.naturalHeight <= 0) return null
const ratio = img.naturalWidth / img.naturalHeight
imageRatio.value = ratio
return ratio
}
function handleImageRef(el: Element | ComponentPublicInstance | null) {
const img = el as HTMLImageElement | null
if (img?.complete && img.naturalWidth > 0) {
const ratio = updateImageRatio(img)
loaded.value = true
failed.value = false
emit('load', ratio)
}
}
function handleImageLoad(event: Event) {
const ratio = updateImageRatio(event.target as HTMLImageElement | null)
loaded.value = true
failed.value = false
emit('load', ratio)
}
function handleImageError() {
loaded.value = false
failed.value = true
imageRatio.value = null
emit('error')
}
</script>
<template>
<template v-if="canRenderImage">
<img
v-if="showBlurredBackdrop"
:src="src ?? ''"
:class="['absolute inset-0 z-0 h-full w-full scale-110 object-cover transition-opacity duration-300 ease-out', backdropClass]"
aria-hidden="true"
loading="lazy"
/>
<span
:class="['book-cover-artwork-frame z-[1]', useNaturalFrame ? 'book-cover-artwork-frame--natural' : 'book-cover-artwork-frame--full']"
:style="frameStyle"
>
<img
:ref="handleImageRef"
:src="src ?? ''"
:alt="alt"
:loading="loading"
:decoding="decoding"
:class="[
'absolute inset-0 h-full w-full transition-opacity duration-300 ease-out',
imageFitClass,
loaded ? 'opacity-100' : 'opacity-0',
imageClass,
]"
@load="handleImageLoad"
@error="handleImageError"
/>
<span v-if="showSpineLayer" class="book-cover-spine-layer absolute inset-0 z-[3]" :style="spineStyle" />
</span>
<span v-if="!loaded" class="absolute inset-0 z-[2] animate-pulse bg-white/10" />
</template>
<span
v-else
:class="[
'absolute inset-0',
effectiveMode === 'natural-bottom' ? 'book-cover-artwork-placeholder-natural overflow-hidden rounded-[inherit]' : '',
]"
>
<BookCoverPlaceholder :title="title" :author-line="authorLine" :is-audio="isAudio" :seed="seed" />
</span>
</template>
@@ -38,8 +38,14 @@ vi.mock('@/features/auth/composables/usePermissions', () => ({
}))
const mockCardOverlays = ref<string[]>([])
const mockBookCoverDisplayMode = ref('blurred-fit')
vi.mock('@/composables/useDisplaySettings', () => ({
useDisplaySettings: () => ({ cardOverlays: mockCardOverlays }),
useDisplaySettings: () => ({
cardOverlays: mockCardOverlays,
bookSpineOverlay: ref('off'),
bookShadowStrength: ref('default'),
bookCoverDisplayMode: mockBookCoverDisplayMode,
}),
}))
const mockDownloadFile = vi.fn<() => void>()
@@ -181,6 +187,7 @@ describe('BookCoverCard', () => {
vi.clearAllMocks()
mockRefreshing.value = false
mockCardOverlays.value = []
mockBookCoverDisplayMode.value = 'blurred-fit'
setTouchMode(false)
})
@@ -327,6 +334,21 @@ describe('BookCoverCard', () => {
expect(mockCoverUrl).toHaveBeenCalledWith(42)
})
it('constrains overlays to the natural fitted cover frame after image load', async () => {
mockBookCoverDisplayMode.value = 'natural-bottom'
const wrapper = mountCard({ book: makeBook({ hasCover: true }) })
const image = wrapper.find('img[alt="Dune"]')
Object.defineProperty(image.element, 'naturalWidth', { configurable: true, value: 1200 })
Object.defineProperty(image.element, 'naturalHeight', { configurable: true, value: 600 })
await image.trigger('load')
const frame = wrapper.find('[data-testid="cover-overlay-frame"]')
expect(frame.attributes('style')).toContain('height:')
expect(frame.attributes('style')).toContain('bottom: 0')
expect(frame.attributes('style')).not.toContain('top: 50%')
})
it('shows missing badge when book status is missing', () => {
const book = makeBook({ status: 'missing' })
const wrapper = mountCard({ book })
@@ -1,9 +1,8 @@
<script setup lang="ts">
import type { BookCard, BookFileRef } from '@bookorbit/types'
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import { bookCoverStyle } from '../lib/book-cover'
import { getFormatColor } from '../lib/format-colors'
import { computed, inject, ref, watch, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
import { computed, inject, ref, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import {
BookOpen,
@@ -43,11 +42,11 @@ import { useRefreshingBooks } from '../composables/useRefreshingBooks'
import { mergeBookCardWithDetail } from '../lib/book-card-mapper'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { coverAspectRatioValue, COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO, fittedCoverFrameStyle } from '../lib/cover-aspect-ratio'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO, coverAspectRatioValue, fittedCoverFrameStyle } from '../lib/cover-aspect-ratio'
import { useBookDownload } from '@/features/book/composables/useBookDownload'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
import BookCoverArtwork from './BookCoverArtwork.vue'
import BookCoverSurface from './BookCoverSurface.vue'
const router = useRouter()
@@ -65,7 +64,6 @@ const emit = defineEmits<{
'update:book': [updated: BookCard]
}>()
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
const authorLine = computed(() => props.book.authors.join(', ') || null)
const authorQuery = computed(() => props.book.authors[0] ?? null)
@@ -112,7 +110,7 @@ async function handleRefreshMetadata() {
if (updated) emit('update:book', mergeBookCardWithDetail(props.book, updated))
}
const { hasPermission } = usePermissions()
const { cardOverlays } = useDisplaySettings()
const { cardOverlays, bookCoverDisplayMode } = useDisplaySettings()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const showSendDialog = ref(false)
@@ -152,6 +150,11 @@ const coverImageRatio = ref<number | null>(null)
const isMissing = computed(() => props.book.status === 'missing')
const showMobileOverlay = ref(false)
const root = ref<HTMLElement | null>(null)
const coverSlotRatio = computed(() => coverAspectRatioValue(String(coverAspectRatio.value)))
const coverOverlayFrameStyle = computed(() => {
if (bookCoverDisplayMode.value !== 'natural-bottom' || !props.book.hasCover || !coverLoaded.value || coverFailed.value) return { inset: '0' }
return fittedCoverFrameStyle(coverImageRatio.value, coverSlotRatio.value, 'bottom')
})
const isTouch = computed(() => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches)
@@ -161,26 +164,16 @@ watch(coverSrc, () => {
coverImageRatio.value = null
})
const slotAspectRatio = computed(() => coverAspectRatioValue(String(coverAspectRatio.value)))
const fittedCoverSpineStyle = computed(() => fittedCoverFrameStyle(coverImageRatio.value, slotAspectRatio.value))
function updateCoverImageRatio(img: HTMLImageElement | null) {
if (!img || img.naturalWidth <= 0 || img.naturalHeight <= 0) return
coverImageRatio.value = img.naturalWidth / img.naturalHeight
}
function onMainImgRef(el: Element | ComponentPublicInstance | null) {
const img = el as HTMLImageElement | null
if (img?.complete && img.naturalWidth > 0) {
coverLoaded.value = true
updateCoverImageRatio(img)
}
}
function handleMainImageLoad(event: Event) {
const target = event.target as HTMLImageElement | null
updateCoverImageRatio(target)
function handleArtworkLoad(ratio: number | null) {
coverLoaded.value = true
coverFailed.value = false
coverImageRatio.value = ratio
}
function handleArtworkError() {
coverLoaded.value = false
coverFailed.value = true
coverImageRatio.value = null
}
function openFile(file: BookFileRef) {
@@ -283,298 +276,273 @@ async function handleSetStatus(status: ReadStatus) {
:class="isMissing || selectionMode ? '' : 'group-hover:scale-[1.02]'"
:interactive="!isMissing && !selectionMode"
:disable-spine="isAudiobook"
:style="[{ aspectRatio: coverAspectRatio }, !book.hasCover || !coverLoaded || coverFailed ? coverStyle : {}]"
:style="{ aspectRatio: coverAspectRatio }"
>
<!-- Missing border overlay: mirror selected overlay pattern so border is never clipped/hidden -->
<div v-if="isMissing" class="absolute inset-0 z-30 pointer-events-none rounded-sm ring-2 ring-inset ring-amber-500" />
<!-- Blurred background fill for mismatched aspect ratios -->
<img
v-if="book.hasCover && coverLoaded && !coverFailed"
<BookCoverArtwork
:src="coverSrc"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-90 transition-opacity duration-300 ease-out"
aria-hidden="true"
loading="lazy"
/>
<img
v-if="book.hasCover && !coverFailed"
:ref="onMainImgRef"
:src="coverSrc"
class="absolute inset-0 w-full h-full object-contain transition-opacity duration-300 ease-out"
:class="[isMissing ? 'brightness-50' : '', coverLoaded ? 'opacity-100' : 'opacity-0']"
loading="lazy"
decoding="async"
:alt="book.title ?? ''"
@load="handleMainImageLoad"
@error="coverFailed = true"
/>
<div
v-if="!isAudiobook && book.hasCover && coverLoaded && !coverFailed"
class="book-cover-spine-layer absolute z-[3]"
:style="fittedCoverSpineStyle"
/>
<!-- Skeleton shimmer while a known-cover loads -->
<div v-if="book.hasCover && !coverLoaded && !coverFailed" class="absolute inset-0 animate-pulse bg-white/10" />
<!-- Top-left overlay: read status -->
<div
v-if="showReadBadge && !isMissing"
class="absolute top-1.5 left-1.5 z-10 flex items-center justify-center rounded-full bg-black/60 p-1 pointer-events-none group-hover:opacity-0 transition-opacity duration-150"
>
<component :is="STATUS_ICONS[localReadStatus!]" :size="12" :class="STATUS_COLORS[localReadStatus!]" />
</div>
<!-- Top-right overlay container: lock status (above) + series position (below) -->
<div class="absolute top-1.5 right-1.5 z-10 flex flex-col items-end gap-1 pointer-events-none">
<div
v-if="showLockStatusPill"
class="flex items-center justify-center rounded-full bg-black/60 p-1 group-hover:opacity-0 transition-opacity duration-150"
>
<component :is="metadataLocked ? Lock : LockOpen" :size="12" :class="metadataLocked ? 'text-amber-400' : 'text-emerald-400'" />
</div>
<Tooltip v-if="showSeriesPositionBadge">
<TooltipTrigger as-child>
<div
data-card-click-blocker
class="pointer-events-auto group-hover:pointer-events-none flex items-center bg-black/60 rounded-full px-1.5 py-0.5 group-hover:opacity-0 transition-opacity duration-150 cursor-default"
>
<span class="text-[9px] font-bold text-white leading-none">{{ seriesPositionLabel }}</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{{ seriesPositionTooltip }}</p>
</TooltipContent>
</Tooltip>
</div>
<!-- Bottom-left overlay: rating -->
<div
v-if="showRatingOverlay && !selectionMode"
class="absolute bottom-1.5 left-1.5 z-10 flex items-center gap-0.5 bg-black/60 rounded-full px-1.5 py-0.5 pointer-events-none group-hover:opacity-0 transition-opacity duration-150"
>
<Star class="size-3" :style="{ fill: ratingColor, color: ratingColor }" />
<span class="text-[9px] font-bold text-white leading-none">{{ Math.round(book.rating!) }}</span>
</div>
<!-- Bottom-right overlay: format badge -->
<div
v-if="showFormatOverlay && !selectionMode"
class="absolute bottom-1.5 right-1.5 z-10 group-hover:opacity-0 transition-opacity duration-150 pointer-events-none"
>
<span
class="text-[8px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded text-white"
:style="{ backgroundColor: getFormatColor(primaryFile!.format!) + 'cc' }"
>
{{ primaryFile!.format!.toUpperCase() }}
</span>
</div>
<!-- Reading progress bar - bottom edge -->
<div
v-if="showProgressBar && !selectionMode"
class="absolute bottom-0 left-0 z-10 h-0.75 transition-[width] duration-500 [box-shadow:0_-1px_0_rgba(255,255,255,0.25)]"
style="transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1)"
:class="book.readingProgress === 100 ? 'bg-green-500/80' : 'bg-primary/70'"
:style="{ width: `${book.readingProgress}%` }"
/>
<!-- Selection checkbox overlay -->
<div
v-if="selectionMode"
class="absolute inset-0 z-30 pointer-events-none rounded-sm"
:class="selected ? 'bg-primary/20 ring-2 ring-inset ring-primary' : ''"
>
<div
class="absolute top-1.5 left-1.5 h-5 w-5 rounded flex items-center justify-center transition-colors"
:class="selected ? 'bg-primary' : 'bg-black/40 border border-white/50'"
>
<Check v-if="selected" class="text-primary-foreground" :size="12" />
</div>
</div>
<!-- Missing badge -->
<div v-if="isMissing" class="absolute top-1.5 right-1.5 z-20">
<span class="flex items-center gap-1 text-[9px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-amber-600/95 text-white">
<TriangleAlert class="size-2.5 shrink-0" />
Missing
</span>
</div>
<!-- Placeholder shown when book has no cover or cover failed to load -->
<BookCoverPlaceholder
v-if="!book.hasCover || coverFailed"
:has-cover="book.hasCover"
:title="book.title"
:author-line="authorLine"
:is-audio="isAudiobook"
:seed="book.title ?? String(book.id)"
:alt="book.title ?? ''"
:image-class="isMissing ? 'brightness-50' : ''"
:spine="!isAudiobook"
@load="handleArtworkLoad"
@error="handleArtworkError"
/>
<!-- Refresh spinner overlay -->
<Transition name="fade">
<div v-if="anyRefreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/50">
<Loader2 class="size-[32cqi] animate-spin text-white drop-shadow-lg" />
</div>
</Transition>
<!-- Hover overlay -->
<div
v-if="!selectionMode"
class="absolute inset-0 flex flex-col p-2 bg-black/70 transition-opacity duration-150"
:class="[showMobileOverlay ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto']"
>
<!-- Top row: Quick View -->
<div class="shrink-0 flex justify-end">
<button class="p-[3cqi] rounded-[2.5cqi] bg-black/50 hover:bg-black/30 transition-colors text-white" @click="openQuickView">
<PanelRight class="size-[12cqi]" />
</button>
<div data-testid="cover-overlay-frame" class="absolute z-10 overflow-hidden rounded-[inherit]" :style="coverOverlayFrameStyle">
<!-- Top-left overlay: read status -->
<div
v-if="showReadBadge && !isMissing"
class="absolute top-1.5 left-1.5 z-10 flex items-center justify-center rounded-full bg-black/60 p-1 pointer-events-none group-hover:opacity-0 transition-opacity duration-150"
>
<component :is="STATUS_ICONS[localReadStatus!]" :size="12" :class="STATUS_COLORS[localReadStatus!]" />
</div>
<!-- Center: Play/Read button -->
<div class="flex-1 flex items-center justify-center">
<button
v-if="primaryFile && !isMissing"
class="size-[30cqi] flex items-center justify-center rounded-full bg-primary text-white shadow-2xl transition-all duration-300 scale-75 hover:scale-110 active:scale-90"
:class="[showMobileOverlay || 'group-hover:scale-100', showMobileOverlay ? 'scale-100' : '']"
@click.stop="openFile(primaryFile)"
<!-- Top-right overlay container: lock status (above) + series position (below) -->
<div class="absolute top-1.5 right-1.5 z-10 flex flex-col items-end gap-1 pointer-events-none">
<div
v-if="showLockStatusPill"
class="flex items-center justify-center rounded-full bg-black/60 p-1 group-hover:opacity-0 transition-opacity duration-150"
>
<component :is="isAudiobook ? Play : BookOpen" class="size-[16cqi]" :class="{ 'ml-[2cqi]': isAudiobook }" />
</button>
</div>
<!-- Bottom: title/author -->
<div class="shrink-0 flex flex-col pr-10">
<div class="flex items-start justify-between gap-2">
<p v-if="showHoverText" class="text-xs font-semibold text-white leading-tight min-w-0 flex-1" :class="hoverTitleClampClass">
{{ book.title ?? '-' }}
</p>
<div v-else class="flex-1" />
<component :is="metadataLocked ? Lock : LockOpen" :size="12" :class="metadataLocked ? 'text-amber-400' : 'text-emerald-400'" />
</div>
<div v-if="showAuthorOnHover" class="min-w-0">
<button class="text-[10px] text-white/70 truncate hover:underline text-left block w-full" @click.stop="openAuthorBrowse">
{{ authorLine }}
<Tooltip v-if="showSeriesPositionBadge">
<TooltipTrigger as-child>
<div
data-card-click-blocker
class="pointer-events-auto group-hover:pointer-events-none flex items-center bg-black/60 rounded-full px-1.5 py-0.5 group-hover:opacity-0 transition-opacity duration-150 cursor-default"
>
<span class="text-[9px] font-bold text-white leading-none">{{ seriesPositionLabel }}</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{{ seriesPositionTooltip }}</p>
</TooltipContent>
</Tooltip>
</div>
<!-- Bottom-left overlay: rating -->
<div
v-if="showRatingOverlay && !selectionMode"
class="absolute bottom-1.5 left-1.5 z-10 flex items-center gap-0.5 bg-black/60 rounded-full px-1.5 py-0.5 pointer-events-none group-hover:opacity-0 transition-opacity duration-150"
>
<Star class="size-3" :style="{ fill: ratingColor, color: ratingColor }" />
<span class="text-[9px] font-bold text-white leading-none">{{ Math.round(book.rating!) }}</span>
</div>
<!-- Bottom-right overlay: format badge -->
<div
v-if="showFormatOverlay && !selectionMode"
class="absolute bottom-1.5 right-1.5 z-10 group-hover:opacity-0 transition-opacity duration-150 pointer-events-none"
>
<span
class="text-[8px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded text-white"
:style="{ backgroundColor: getFormatColor(primaryFile!.format!) + 'cc' }"
>
{{ primaryFile!.format!.toUpperCase() }}
</span>
</div>
<!-- Reading progress bar - bottom edge -->
<div
v-if="showProgressBar && !selectionMode"
class="absolute bottom-0 left-0 z-10 h-0.75 transition-[width] duration-500 [box-shadow:0_-1px_0_rgba(255,255,255,0.25)]"
style="transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1)"
:class="book.readingProgress === 100 ? 'bg-green-500/80' : 'bg-primary/70'"
:style="{ width: `${book.readingProgress}%` }"
/>
<div
v-if="selectionMode"
class="absolute inset-0 z-30 pointer-events-none rounded-sm"
:class="selected ? 'bg-primary/20 ring-2 ring-inset ring-primary' : ''"
>
<div
class="absolute top-1.5 left-1.5 h-5 w-5 rounded flex items-center justify-center transition-colors"
:class="selected ? 'bg-primary' : 'bg-black/40 border border-white/50'"
>
<Check v-if="selected" class="text-primary-foreground" :size="12" />
</div>
</div>
<!-- Missing badge -->
<div v-if="isMissing" class="absolute top-1.5 right-1.5 z-20">
<span class="flex items-center gap-1 text-[9px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-amber-600/95 text-white">
<TriangleAlert class="size-2.5 shrink-0" />
Missing
</span>
</div>
<!-- Refresh spinner overlay -->
<Transition name="fade">
<div v-if="anyRefreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/50">
<Loader2 class="size-[32cqi] animate-spin text-white drop-shadow-lg" />
</div>
</Transition>
<!-- Hover overlay -->
<div
v-if="!selectionMode"
class="absolute inset-0 flex flex-col p-2 bg-black/70 transition-opacity duration-150"
:class="[showMobileOverlay ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto']"
>
<!-- Top row: Quick View -->
<div class="shrink-0 flex justify-end">
<button class="p-[3cqi] rounded-[2.5cqi] bg-black/50 hover:bg-black/30 transition-colors text-white" @click="openQuickView">
<PanelRight class="size-[12cqi]" />
</button>
</div>
</div>
<!-- Kebab menu anchored to lower-right -->
<div class="absolute bottom-2 right-2 z-20">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="px-0.75 py-1.5 rounded-md bg-black/40 hover:bg-white/30 transition-colors text-white shrink-0">
<MoreVertical class="size-3.5" />
<!-- Center: Play/Read button -->
<div class="flex-1 flex items-center justify-center">
<button
v-if="primaryFile && !isMissing"
class="size-[30cqi] flex items-center justify-center rounded-full bg-primary text-white shadow-2xl transition-all duration-300 scale-75 hover:scale-110 active:scale-90"
:class="[showMobileOverlay || 'group-hover:scale-100', showMobileOverlay ? 'scale-100' : '']"
@click.stop="openFile(primaryFile)"
>
<component :is="isAudiobook ? Play : BookOpen" class="size-[16cqi]" :class="{ 'ml-[2cqi]': isAudiobook }" />
</button>
</div>
<!-- Bottom: title/author -->
<div class="shrink-0 flex flex-col pr-10">
<div class="flex items-start justify-between gap-2">
<p v-if="showHoverText" class="text-xs font-semibold text-white leading-tight min-w-0 flex-1" :class="hoverTitleClampClass">
{{ book.title ?? '-' }}
</p>
<div v-else class="flex-1" />
</div>
<div v-if="showAuthorOnHover" class="min-w-0">
<button class="text-[10px] text-white/70 truncate hover:underline text-left block w-full" @click.stop="openAuthorBrowse">
{{ authorLine }}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem v-if="openableFiles.length <= 1 && primaryFile && !isMissing" @click="openFile(primaryFile)">
<BookOpen class="size-4 mr-2" />
Open
</DropdownMenuItem>
<DropdownMenuSub v-else-if="openableFiles.length > 1 && !isMissing">
<DropdownMenuSubTrigger>
</div>
</div>
<!-- Kebab menu anchored to lower-right -->
<div class="absolute bottom-2 right-2 z-20">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="px-0.75 py-1.5 rounded-md bg-black/40 hover:bg-white/30 transition-colors text-white shrink-0">
<MoreVertical class="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem v-if="openableFiles.length <= 1 && primaryFile && !isMissing" @click="openFile(primaryFile)">
<BookOpen class="size-4 mr-2" />
Open
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="openFile(file)">
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuItem>
<DropdownMenuSub v-else-if="openableFiles.length > 1 && !isMissing">
<DropdownMenuSubTrigger>
<BookOpen class="size-4 mr-2" />
Open
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="openFile(file)">
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<!-- Download submenu -->
<DropdownMenuItem
v-if="hasPermission('library_download') && openableFiles.length === 1 && primaryFile"
@click="handleDownloadFile(primaryFile)"
>
<Download class="size-4 mr-2" />
Download
</DropdownMenuItem>
<DropdownMenuSub v-else-if="hasPermission('library_download') && openableFiles.length > 1">
<DropdownMenuSubTrigger>
<!-- Download submenu -->
<DropdownMenuItem
v-if="hasPermission('library_download') && openableFiles.length === 1 && primaryFile"
@click="handleDownloadFile(primaryFile)"
>
<Download class="size-4 mr-2" />
Download
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="handleDownloadFile(file)">
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleExportAll"> All formats (ZIP) </DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuItem>
<DropdownMenuSub v-else-if="hasPermission('library_download') && openableFiles.length > 1">
<DropdownMenuSubTrigger>
<Download class="size-4 mr-2" />
Download
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="handleDownloadFile(file)">
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleExportAll"> All formats (ZIP) </DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem @click="router.push({ name: 'book-detail', params: { bookId: book.id } })">
<ExternalLink class="size-4 mr-2" />
Book Details
</DropdownMenuItem>
<DropdownMenuSeparator v-if="hasPermission('library_edit_metadata')" />
<DropdownMenuSub v-if="hasPermission('library_edit_metadata')">
<DropdownMenuSubTrigger>
<Pencil class="size-4 mr-2" />
Metadata
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem @click="router.push({ name: 'book-detail', params: { bookId: book.id }, query: { tab: 'edit' } })">
<DropdownMenuItem @click="router.push({ name: 'book-detail', params: { bookId: book.id } })">
<ExternalLink class="size-4 mr-2" />
Book Details
</DropdownMenuItem>
<DropdownMenuSeparator v-if="hasPermission('library_edit_metadata')" />
<DropdownMenuSub v-if="hasPermission('library_edit_metadata')">
<DropdownMenuSubTrigger>
<Pencil class="size-4 mr-2" />
Edit Metadata
</DropdownMenuItem>
<DropdownMenuItem :disabled="anyRefreshing" @click="handleRefreshMetadata">
<Loader2 v-if="anyRefreshing" class="size-4 mr-2 animate-spin" />
<RefreshCw v-else class="size-4 mr-2" />
Refresh Metadata
</DropdownMenuItem>
<DropdownMenuItem :disabled="reExtractingCover" @click="reExtractCover()">
<Loader2 v-if="reExtractingCover" class="size-4 mr-2 animate-spin" />
<Image v-else class="size-4 mr-2" />
Regenerate Cover
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem @click="emit('action', 'add-to-collection')">
<FolderPlus class="size-4 mr-2" />
Add to Collection
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<component
:is="STATUS_ICONS[localReadStatus ?? 'unread']"
class="size-4 mr-2"
:class="STATUS_COLORS[localReadStatus ?? 'unread']"
/>
Set Status
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem v-for="opt in STATUS_OPTIONS" :key="opt.value" @click="handleSetStatus(opt.value)">
<component :is="STATUS_ICONS[opt.value]" class="size-4 mr-2" :class="STATUS_COLORS[opt.value]" />
{{ opt.label }}
<Check v-if="localReadStatus === opt.value" class="size-3 ml-auto text-primary" />
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem v-if="hasPermission('email_send')" @click="showSendDialog = true">
<Send class="size-4 mr-2" />
Send via Email
</DropdownMenuItem>
<DropdownMenuSeparator v-if="hasPermission('email_send') || hasPermission('library_delete_books')" />
<DropdownMenuItem
v-if="hasPermission('library_delete_books')"
class="text-destructive focus:text-destructive"
@click="emit('action', 'delete')"
>
<Trash2 class="size-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
Metadata
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem @click="router.push({ name: 'book-detail', params: { bookId: book.id }, query: { tab: 'edit' } })">
<Pencil class="size-4 mr-2" />
Edit Metadata
</DropdownMenuItem>
<DropdownMenuItem :disabled="anyRefreshing" @click="handleRefreshMetadata">
<Loader2 v-if="anyRefreshing" class="size-4 mr-2 animate-spin" />
<RefreshCw v-else class="size-4 mr-2" />
Refresh Metadata
</DropdownMenuItem>
<DropdownMenuItem :disabled="reExtractingCover" @click="reExtractCover()">
<Loader2 v-if="reExtractingCover" class="size-4 mr-2 animate-spin" />
<Image v-else class="size-4 mr-2" />
Regenerate Cover
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem @click="emit('action', 'add-to-collection')">
<FolderPlus class="size-4 mr-2" />
Add to Collection
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<component
:is="STATUS_ICONS[localReadStatus ?? 'unread']"
class="size-4 mr-2"
:class="STATUS_COLORS[localReadStatus ?? 'unread']"
/>
Set Status
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem v-for="opt in STATUS_OPTIONS" :key="opt.value" @click="handleSetStatus(opt.value)">
<component :is="STATUS_ICONS[opt.value]" class="size-4 mr-2" :class="STATUS_COLORS[opt.value]" />
{{ opt.label }}
<Check v-if="localReadStatus === opt.value" class="size-3 ml-auto text-primary" />
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem v-if="hasPermission('email_send')" @click="showSendDialog = true">
<Send class="size-4 mr-2" />
Send via Email
</DropdownMenuItem>
<DropdownMenuSeparator v-if="hasPermission('email_send') || hasPermission('library_delete_books')" />
<DropdownMenuItem
v-if="hasPermission('library_delete_books')"
class="text-destructive focus:text-destructive"
@click="emit('action', 'delete')"
>
<Trash2 class="size-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</BookCoverSurface>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { BookCoverDisplayMode } from '@bookorbit/types'
import { computed } from 'vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
@@ -8,16 +9,18 @@ const props = withDefaults(
interactive?: boolean
tag?: string
disableSpine?: boolean
displayMode?: BookCoverDisplayMode
}>(),
{
size: 'default',
interactive: false,
tag: 'div',
disableSpine: false,
displayMode: undefined,
},
)
const { bookSpineOverlay, bookShadowStrength } = useDisplaySettings()
const { bookSpineOverlay, bookShadowStrength, bookCoverDisplayMode } = useDisplaySettings()
const spineOverlayMode = computed(() => {
if (props.disableSpine) return 'off'
@@ -25,6 +28,7 @@ const spineOverlayMode = computed(() => {
})
const shadowStrengthMode = computed(() => bookShadowStrength?.value ?? 'default')
const coverDisplayMode = computed(() => props.displayMode ?? bookCoverDisplayMode?.value ?? 'blurred-fit')
const classes = computed(() => [
'book-cover-surface',
@@ -40,6 +44,7 @@ const classes = computed(() => [
:data-cover-size="size"
:data-cover-shadow="shadowStrengthMode"
:data-cover-spine="spineOverlayMode"
:data-cover-fit="coverDisplayMode"
:data-cover-interactive="interactive ? 'true' : 'false'"
>
<slot />
@@ -1,8 +1,7 @@
<script setup lang="ts">
import type { BookCard, BookFileRef } from '@bookorbit/types'
import { FORMAT_TO_GROUP } from '@bookorbit/types'
import { bookCoverStyle } from '../lib/book-cover'
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
import BookCoverArtwork from './BookCoverArtwork.vue'
import BookCoverSurface from './BookCoverSurface.vue'
import { api } from '@/lib/api'
import { computed, inject, ref, watch } from 'vue'
@@ -25,7 +24,7 @@ import {
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCoverVersions } from '../composables/useCoverVersions'
import { coverAspectRatioValue, COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO, fittedCoverFrameStyle } from '../lib/cover-aspect-ratio'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { useRefreshMetadata } from '../composables/useRefreshMetadata'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
@@ -49,7 +48,6 @@ const emit = defineEmits<{
const { hasPermission } = usePermissions()
const showSendDialog = ref(false)
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
const authorLine = computed(() => props.book.authors.join(', ') || null)
const authorQuery = computed(() => props.book.authors[0] ?? null)
const seriesLine = computed(() => {
@@ -72,10 +70,6 @@ const metaLine = computed(() => {
const visibleTags = computed(() => props.book.genres.slice(0, 2))
const coverLoaded = ref(false)
const coverFailed = ref(false)
const coverImageRatio = ref<number | null>(null)
const localRating = ref<number | null>(props.book.rating)
const hoverRating = ref<number | null>(null)
const displayRating = computed(() => hoverRating.value ?? localRating.value)
@@ -101,24 +95,8 @@ async function setRating(star: number) {
const { coverUrl } = useCoverVersions()
const coverSrc = computed(() => coverUrl(props.book.id))
watch(coverSrc, () => {
coverLoaded.value = false
coverFailed.value = false
coverImageRatio.value = null
})
const { refreshing, refreshWithFeedback } = useRefreshMetadata()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const slotAspectRatio = computed(() => coverAspectRatioValue(String(coverAspectRatio.value)))
const fittedCoverSpineStyle = computed(() => fittedCoverFrameStyle(coverImageRatio.value, slotAspectRatio.value))
function handleCoverLoad(event: Event) {
const target = event.target as HTMLImageElement | null
if (target && target.naturalWidth > 0 && target.naturalHeight > 0) {
coverImageRatio.value = target.naturalWidth / target.naturalHeight
}
coverLoaded.value = true
}
function openFile(file: BookFileRef) {
router.push({
@@ -159,37 +137,18 @@ function openAuthorBrowse() {
class="book-cover-surface--spine-fitted w-16 rounded shrink-0 overflow-hidden relative"
:disable-spine="isAudiobook"
:class="isMissing ? 'opacity-50 grayscale' : ''"
:style="[{ aspectRatio: coverAspectRatio }, !book.hasCover || !coverLoaded || coverFailed ? coverStyle : {}]"
:style="{ aspectRatio: coverAspectRatio }"
>
<!-- Blurred background fill for mismatched aspect ratios -->
<img
v-if="book.hasCover && coverLoaded && !coverFailed"
<BookCoverArtwork
:src="coverSrc"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-50"
aria-hidden="true"
loading="lazy"
/>
<img
v-if="book.hasCover && !coverFailed"
:src="coverSrc"
class="absolute inset-0 w-full h-full object-contain"
loading="lazy"
decoding="async"
:alt="book.title ?? ''"
@load="handleCoverLoad"
@error="coverFailed = true"
/>
<div
v-if="!isAudiobook && book.hasCover && coverLoaded && !coverFailed"
class="book-cover-spine-layer absolute z-[3]"
:style="fittedCoverSpineStyle"
/>
<BookCoverPlaceholder
v-if="!book.hasCover || coverFailed"
:has-cover="book.hasCover"
:title="book.title"
:author-line="authorLine"
:is-audio="isAudiobook"
:seed="book.title ?? String(book.id)"
:alt="book.title ?? ''"
backdrop-class="blur-md brightness-50"
:spine="!isAudiobook"
/>
</BookCoverSurface>
@@ -11,13 +11,14 @@ import { getProviderColor } from '@/lib/provider-colors'
import { providerIconPath } from '@/features/book/lib/provider-icons'
import { useBookDetail } from '../composables/useBookDetail'
import { useCoverVersions } from '../composables/useCoverVersions'
import { bookCoverStyle } from '../lib/book-cover'
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
import { getFormatColor } from '../lib/format-colors'
import { FORMAT_TO_GROUP } from '@bookorbit/types'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
import BookCoverArtwork from './BookCoverArtwork.vue'
import BookCoverSurface from './BookCoverSurface.vue'
const props = defineProps<{ bookId: number | null; open: boolean }>()
const { hasPermission } = usePermissions()
@@ -39,6 +40,7 @@ const { detail, loading, fetch } = useBookDetail()
const coverLoaded = ref(false)
const coverFailed = ref(false)
const coverImageRatio = ref<number | null>(null)
const providerIconErrors = ref<Record<string, boolean>>({})
const descriptionExpanded = ref(false)
const genresExpanded = ref(false)
@@ -50,6 +52,7 @@ watch(
if (id !== null) {
coverLoaded.value = false
coverFailed.value = false
coverImageRatio.value = null
descriptionExpanded.value = false
genresExpanded.value = false
providerIconErrors.value = {}
@@ -64,7 +67,6 @@ const coverSrc = computed(() => (detail.value ? coverUrl(detail.value.id, 'cover
const coverSeed = computed(() => (detail.value ? (detail.value.title ?? detail.value.folderPath.split('/').pop() ?? String(detail.value.id)) : ''))
const coverPlaceholderTitle = computed(() => (detail.value ? (detail.value.title ?? detail.value.folderPath.split('/').pop() ?? null) : null))
const coverStyle = computed(() => (detail.value ? bookCoverStyle(coverSeed.value) : {}))
const seriesLine = computed(() => {
if (!detail.value?.seriesName) return null
@@ -139,6 +141,20 @@ const providerLinks = computed<ProviderLink[]>(() => {
const safeDescription = useSafeHtml(() => detail.value?.description)
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const { bookCoverDisplayMode } = useDisplaySettings()
const quickViewCoverAspectRatio = computed(() => {
if (
bookCoverDisplayMode.value !== 'natural-bottom' ||
detail.value?.coverSource == null ||
!coverLoaded.value ||
coverFailed.value ||
!coverImageRatio.value
) {
return coverAspectRatio.value
}
return `${coverImageRatio.value} / 1`
})
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')
@@ -163,6 +179,22 @@ function formatBadgeStyle(fmt: string) {
}
}
function handleCoverLoad(ratio: number | null) {
coverLoaded.value = true
coverFailed.value = false
coverImageRatio.value = ratio
}
function handleCoverError() {
coverLoaded.value = false
coverFailed.value = true
coverImageRatio.value = null
}
function handleCoverClick() {
if (coverLoaded.value && !coverFailed.value) coverLightboxOpen.value = true
}
function openBook() {
if (!primaryFile.value || !detail.value) return
router.push({
@@ -220,29 +252,29 @@ function handleDelete() {
<div v-else-if="detail" class="flex gap-4 items-start">
<!-- Cover -->
<div
class="w-24 shrink-0 rounded overflow-hidden shadow-md relative"
<BookCoverSurface
size="mini"
class="book-cover-surface--spine-fitted w-24 shrink-0 rounded overflow-hidden relative"
:disable-spine="isPrimaryAudio"
:class="detail.coverSource && !coverFailed ? 'cursor-zoom-in' : ''"
:style="[{ aspectRatio: coverAspectRatio }, !detail.coverSource || !coverLoaded || coverFailed ? coverStyle : {}]"
@click="coverLoaded && !coverFailed && (coverLightboxOpen = true)"
:style="{ aspectRatio: quickViewCoverAspectRatio }"
@click="handleCoverClick"
>
<img
v-if="detail.coverSource && !coverFailed"
:src="coverSrc!"
class="w-full h-full object-contain transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
:alt="detail.title ?? ''"
@load="coverLoaded = true"
@error="coverFailed = true"
/>
<BookCoverPlaceholder
v-if="!detail.coverSource || coverFailed"
<BookCoverArtwork
:src="coverSrc"
:has-cover="detail.coverSource !== null"
:title="coverPlaceholderTitle"
:author-line="authorLine"
:is-audio="isPrimaryAudio"
:seed="coverSeed"
:alt="detail.title ?? ''"
:frame-aspect-ratio="quickViewCoverAspectRatio"
loading="eager"
:spine="!isPrimaryAudio"
@load="handleCoverLoad"
@error="handleCoverError"
/>
</div>
</BookCoverSurface>
<!-- Info -->
<div class="flex-1 min-w-0 pr-2">
@@ -482,7 +514,7 @@ function handleDelete() {
>
<DialogTitle class="sr-only">{{ detail?.title ? `${detail.title} cover preview` : 'Book cover preview' }}</DialogTitle>
<DialogDescription class="sr-only">Enlarged cover image preview dialog.</DialogDescription>
<img v-if="detail" :src="coverSrc!" :alt="detail.title ?? ''" class="max-w-[90vw] max-h-[90vh] rounded-md shadow-2xl object-contain" />
<img v-if="detail" :src="coverSrc ?? ''" :alt="detail.title ?? ''" class="max-w-[90vw] max-h-[90vh] rounded-md shadow-2xl object-contain" />
<DialogClose
class="absolute -top-3 -right-3 p-1 rounded-full bg-background border border-border text-muted-foreground hover:text-foreground transition-colors"
>
@@ -0,0 +1,146 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { nextTick, ref } from 'vue'
import type { BookCoverDisplayMode } from '@bookorbit/types'
import { COVER_ASPECT_RATIO_KEY } from '@/features/book/lib/cover-aspect-ratio'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import BookCoverArtwork from '../BookCoverArtwork.vue'
const { bookCoverDisplayMode } = useDisplaySettings()
function mountArtwork(
options: {
mode?: BookCoverDisplayMode
hasCover?: boolean
src?: string | null
frameAspectRatio?: string
spine?: boolean
} = {},
) {
return mount(BookCoverArtwork, {
props: {
src: options.src ?? '/cover.jpg',
hasCover: options.hasCover ?? true,
title: 'Dune',
authorLine: 'Frank Herbert',
isAudio: false,
seed: 'Dune',
alt: 'Dune cover',
mode: options.mode,
frameAspectRatio: options.frameAspectRatio,
spine: options.spine,
},
global: {
provide: {
[COVER_ASPECT_RATIO_KEY as symbol]: ref('2/3'),
},
stubs: {
BookCoverPlaceholder: {
name: 'BookCoverPlaceholder',
props: ['title', 'authorLine', 'isAudio', 'seed'],
template: '<div data-testid="placeholder" :data-title="title" :data-author="authorLine" />',
},
},
},
})
}
async function triggerMainImageLoad(wrapper: ReturnType<typeof mountArtwork>, naturalWidth: number, naturalHeight: number) {
const image = wrapper.findAll('img').find((img) => img.attributes('alt') === 'Dune cover')
if (!image) throw new Error('Expected main cover image')
Object.defineProperty(image.element, 'naturalWidth', { value: naturalWidth, configurable: true })
Object.defineProperty(image.element, 'naturalHeight', { value: naturalHeight, configurable: true })
await image.trigger('load')
await nextTick()
return image
}
afterEach(() => {
bookCoverDisplayMode.value = 'blurred-fit'
})
describe('BookCoverArtwork', () => {
it('renders a placeholder when the book has no cover', () => {
const wrapper = mountArtwork({ hasCover: false })
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(false)
})
it('renders blurred-fit covers with an object-contain image and backdrop after load', async () => {
const wrapper = mountArtwork({ mode: 'blurred-fit' })
const image = await triggerMainImageLoad(wrapper, 600, 900)
expect(image.classes()).toContain('object-contain')
expect(wrapper.emitted('load')?.[0]).toEqual([600 / 900])
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
expect(images[0]?.classes()).toContain('blur-md')
expect(wrapper.find('.book-cover-spine-layer').exists()).toBe(true)
})
it('renders fill-crop covers without a blurred backdrop', async () => {
const wrapper = mountArtwork({ mode: 'fill-crop' })
const image = await triggerMainImageLoad(wrapper, 600, 900)
expect(image.classes()).toContain('object-cover')
expect(wrapper.findAll('img')).toHaveLength(1)
expect(wrapper.find('.book-cover-artwork-frame--full').exists()).toBe(true)
})
it('bottom-aligns landscape natural covers inside the slot', async () => {
const wrapper = mountArtwork({ mode: 'natural-bottom' })
await triggerMainImageLoad(wrapper, 1200, 600)
const frame = wrapper.find('.book-cover-artwork-frame--natural')
expect(frame.exists()).toBe(true)
expect(frame.attributes('style')).toContain('height:')
expect(frame.attributes('style')).toContain('bottom: 0')
expect(frame.attributes('style')).not.toContain('translateY')
})
it('centers narrow natural covers while preserving full height', async () => {
const wrapper = mountArtwork({ mode: 'natural-bottom' })
await triggerMainImageLoad(wrapper, 400, 800)
const frame = wrapper.find('.book-cover-artwork-frame--natural')
expect(frame.attributes('style')).toContain('width: 75%')
expect(frame.attributes('style')).toContain('height: 100%')
expect(frame.attributes('style')).toContain('translateX(-50%)')
})
it('uses the global display mode when no explicit mode is provided', async () => {
bookCoverDisplayMode.value = 'fill-crop'
const wrapper = mountArtwork()
const image = await triggerMainImageLoad(wrapper, 600, 900)
expect(image.classes()).toContain('object-cover')
expect(wrapper.findAll('img')).toHaveLength(1)
})
it('falls back to the placeholder on image error and retries after resetKey changes', async () => {
const wrapper = mountArtwork()
const image = wrapper.find('img[alt="Dune cover"]')
await image.trigger('error')
expect(wrapper.emitted('error')).toHaveLength(1)
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(true)
await wrapper.setProps({ resetKey: 'retry-1' })
expect(wrapper.find('img[alt="Dune cover"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(false)
})
it('does not render the fitted spine layer when spine is disabled', async () => {
const wrapper = mountArtwork({ spine: false })
await triggerMainImageLoad(wrapper, 600, 900)
expect(wrapper.find('.book-cover-spine-layer').exists()).toBe(false)
})
})
@@ -3,11 +3,12 @@ import { afterEach, describe, expect, it } from 'vitest'
import BookCoverSurface from '../BookCoverSurface.vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
const { bookSpineOverlay, bookShadowStrength } = useDisplaySettings()
const { bookSpineOverlay, bookShadowStrength, bookCoverDisplayMode } = useDisplaySettings()
afterEach(() => {
bookSpineOverlay.value = 'off'
bookShadowStrength.value = 'default'
bookCoverDisplayMode.value = 'blurred-fit'
})
describe('BookCoverSurface', () => {
@@ -30,10 +31,28 @@ describe('BookCoverSurface', () => {
const wrapper = mount(BookCoverSurface)
expect(wrapper.attributes('data-cover-spine')).toBe('strong')
expect(wrapper.attributes('data-cover-shadow')).toBe('strong')
expect(wrapper.attributes('data-cover-fit')).toBe('blurred-fit')
expect(wrapper.attributes('data-cover-size')).toBe('default')
expect(wrapper.attributes('data-cover-interactive')).toBe('false')
})
it('applies the selected cover display mode from display settings', async () => {
const wrapper = mount(BookCoverSurface)
bookCoverDisplayMode.value = 'natural-bottom'
await wrapper.vm.$nextTick()
expect(wrapper.attributes('data-cover-fit')).toBe('natural-bottom')
})
it('allows an explicit display mode override', () => {
bookCoverDisplayMode.value = 'natural-bottom'
const wrapper = mount(BookCoverSurface, {
props: { displayMode: 'fill-crop' },
})
expect(wrapper.attributes('data-cover-fit')).toBe('fill-crop')
})
it('renders dynamic tag and forwards native attrs', () => {
const wrapper = mount(BookCoverSurface, {
props: { tag: 'button', interactive: true },
@@ -4,6 +4,10 @@ import { ref } from 'vue'
import type { BookDetail } from '@bookorbit/types'
import BookQuickView from '../BookQuickView.vue'
const displaySettings = {
bookCoverDisplayMode: ref('blurred-fit'),
}
const permissionState = {
canDelete: true,
canEditMetadata: true,
@@ -53,6 +57,10 @@ vi.mock('@/features/book/composables/useSafeHtml', () => ({
useSafeHtml: () => '',
}))
vi.mock('@/composables/useDisplaySettings', () => ({
useDisplaySettings: () => displaySettings,
}))
function makeDetail(overrides: Partial<BookDetail> = {}): BookDetail {
return {
id: 42,
@@ -131,6 +139,7 @@ describe('BookQuickView', () => {
fetchMock.mockReset()
pushMock.mockReset()
loadingRef.value = false
displaySettings.bookCoverDisplayMode.value = 'blurred-fit'
detailRef.value = makeDetail()
})
@@ -189,4 +198,25 @@ describe('BookQuickView', () => {
expect(wrapper.find('[data-testid="quick-view-action-delete"]').exists()).toBe(false)
})
it('shrinks natural-bottom quick-view cover to the loaded cover ratio', async () => {
displaySettings.bookCoverDisplayMode.value = 'natural-bottom'
detailRef.value = makeDetail({ coverSource: 'extracted' })
const wrapper = mount(BookQuickView, {
props: {
open: true,
bookId: 42,
},
global: globalStubs,
})
const image = wrapper.get('img[alt="Quick View Book"]')
Object.defineProperty(image.element, 'naturalWidth', { configurable: true, value: 1200 })
Object.defineProperty(image.element, 'naturalHeight', { configurable: true, value: 600 })
await image.trigger('load')
const surface = wrapper.get('.book-cover-surface')
expect(surface.attributes('style')).toContain('aspect-ratio: 2 / 1')
})
})
@@ -262,19 +262,19 @@ describe('BookCarousel', () => {
it('shows series index badge when showSeriesIndex is true and seriesIndex is set', () => {
const book = makeBook({ seriesIndex: 3 })
const wrapper = mountCarousel([book], { showSeriesIndex: true })
expect(wrapper.find('span').text()).toContain('#3')
expect(wrapper.text()).toContain('#3')
})
it('does not show series index badge when showSeriesIndex is false', () => {
const book = makeBook({ seriesIndex: 3 })
const wrapper = mountCarousel([book], { showSeriesIndex: false })
expect(wrapper.find('span').exists()).toBe(false)
expect(wrapper.text()).not.toContain('#3')
})
it('does not show series index badge when seriesIndex is null', () => {
const book = makeBook({ seriesIndex: null })
const wrapper = mountCarousel([book], { showSeriesIndex: true })
expect(wrapper.find('span').exists()).toBe(false)
expect(wrapper.text()).not.toContain('#')
})
it('renders multiple books', () => {
@@ -3,11 +3,10 @@ import { nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
import BookCoverSurface from '@/features/book/components/BookCoverSurface.vue'
import { coverAspectRatioValue, DEFAULT_COVER_ASPECT_RATIO, fittedCoverFrameStyle } from '@/features/book/lib/cover-aspect-ratio'
import { DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
export interface CarouselBook {
id: number
@@ -36,15 +35,7 @@ const props = withDefaults(
const router = useRouter()
const { coverUrl } = useCoverVersions()
const scrollEl = ref<HTMLElement | null>(null)
const slotAspectRatio = coverAspectRatioValue(DEFAULT_COVER_ASPECT_RATIO)
type CoverState = {
loaded: boolean
failed: boolean
imageRatio: number | null
}
const coverStateByBookId = ref<Record<number, CoverState>>({})
const coverResetVersion = ref(0)
function scroll(direction: 'left' | 'right') {
if (!scrollEl.value) return
@@ -68,48 +59,10 @@ function cardAspectRatio(book: CarouselBook): string {
return isAudiobook(book) ? '1/1' : DEFAULT_COVER_ASPECT_RATIO
}
function coverState(bookId: number): CoverState {
return coverStateByBookId.value[bookId] ?? { loaded: false, failed: false, imageRatio: null }
}
function isCoverLoaded(bookId: number): boolean {
return coverState(bookId).loaded
}
function isCoverFailed(bookId: number): boolean {
return coverState(bookId).failed
}
function fittedCoverSpineStyle(bookId: number): Record<string, string> {
return fittedCoverFrameStyle(coverState(bookId).imageRatio, slotAspectRatio)
}
function handleCoverLoad(bookId: number, event: Event): void {
const target = event.target as HTMLImageElement | null
const imageRatio = target && target.naturalWidth > 0 && target.naturalHeight > 0 ? target.naturalWidth / target.naturalHeight : null
coverStateByBookId.value = {
...coverStateByBookId.value,
[bookId]: { loaded: true, failed: false, imageRatio },
}
}
function handleCoverError(bookId: number): void {
coverStateByBookId.value = {
...coverStateByBookId.value,
[bookId]: { loaded: false, failed: true, imageRatio: null },
}
}
watch(
() => props.books,
(books) => {
const previous = coverStateByBookId.value
const next: Record<number, CoverState> = {}
for (const book of books) {
const existing = previous[book.id]
next[book.id] = existing ? { ...existing, failed: false } : { loaded: false, failed: false, imageRatio: null }
}
coverStateByBookId.value = next
() => {
coverResetVersion.value += 1
},
{ immediate: true },
)
@@ -168,40 +121,21 @@ 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)"
:style="[
{ aspectRatio: cardAspectRatio(book) },
!book.hasCover || !isCoverLoaded(book.id) || isCoverFailed(book.id) ? bookCoverStyle(book.title ?? String(book.id)) : {},
]"
:display-mode="isAudiobook(book) ? 'fill-crop' : undefined"
:style="{ aspectRatio: cardAspectRatio(book) }"
>
<img
v-if="book.hasCover && isCoverLoaded(book.id) && !isCoverFailed(book.id) && !isAudiobook(book)"
<BookCoverArtwork
:src="coverUrl(book.id, 'thumbnail')"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-90 transition-opacity duration-300 ease-out"
aria-hidden="true"
loading="lazy"
/>
<img
v-if="book.hasCover && !isCoverFailed(book.id)"
:src="coverUrl(book.id, 'thumbnail')"
:alt="book.title ?? ''"
class="absolute inset-0 w-full h-full transition-opacity duration-300 ease-out"
:class="[isAudiobook(book) ? 'object-cover' : 'object-contain', isCoverLoaded(book.id) ? 'opacity-100' : 'opacity-0']"
loading="lazy"
@load="handleCoverLoad(book.id, $event)"
@error="handleCoverError(book.id)"
/>
<div v-if="book.hasCover && !isCoverLoaded(book.id) && !isCoverFailed(book.id)" class="absolute inset-0 animate-pulse bg-white/10" />
<div
v-if="!isAudiobook(book) && book.hasCover && isCoverLoaded(book.id) && !isCoverFailed(book.id)"
class="book-cover-spine-layer absolute z-[3]"
:style="fittedCoverSpineStyle(book.id)"
/>
<BookCoverPlaceholder
v-if="!book.hasCover || isCoverFailed(book.id)"
:has-cover="book.hasCover"
:title="book.title"
:author-line="book.authors.length > 0 ? book.authors.join(', ') : null"
:is-audio="isAudiobook(book)"
:seed="book.title ?? String(book.id)"
:alt="book.title ?? ''"
:frame-aspect-ratio="cardAspectRatio(book)"
:mode="isAudiobook(book) ? 'fill-crop' : undefined"
:reset-key="`${coverResetVersion}:${book.id}`"
:spine="!isAudiobook(book)"
/>
<span
v-if="showSeriesIndex && book.seriesIndex != null"
@@ -17,23 +17,17 @@ import {
X,
} from 'lucide-vue-next'
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { getFormatColor } from '@/features/book/lib/format-colors'
import { providerIconPath } from '@/features/book/lib/provider-icons'
import { getProviderColor } from '@/lib/provider-colors'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import {
coverAspectRatioValue,
COVER_ASPECT_RATIO_KEY,
DEFAULT_COVER_ASPECT_RATIO,
fittedCoverFrameStyle,
} from '@/features/book/lib/cover-aspect-ratio'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import type { BookDetail, BookKoboState, ReadStatus, UserBookStatus } from '@bookorbit/types'
import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/features/book/composables/useBookStatus'
import BookDownloadButton from '@/features/book/components/BookDownloadButton.vue'
import DiscoverRow from '@/features/book/components/detail/DiscoverRow.vue'
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
@@ -50,6 +44,7 @@ import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
import { useKoreaderBookProgress } from '@/features/koreader/composables/useKoreaderBookProgress'
import { RATING_STARS, getRatingStarClass } from '@/features/book/lib/rating-stars'
import BookCoverSurface from '@/features/book/components/BookCoverSurface.vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
type FileProgress = {
percentage: number
@@ -106,6 +101,7 @@ const {
const coverLoaded = ref(false)
const coverFailed = ref(false)
const coverImageRatio = ref<number | null>(null)
const coverLightboxOpen = ref(false)
const descriptionExpanded = ref(false)
const genresExpanded = ref(false)
@@ -263,12 +259,10 @@ const canViewKoreader = computed(() => hasPermission('koreader_sync'))
const canEditMetadata = computed(() => hasPermission('library_edit_metadata'))
const coverSeed = computed(() => props.book.title ?? props.book.folderPath.split('/').pop() ?? String(props.book.id))
const coverStyle = computed(() => bookCoverStyle(coverSeed.value))
const coverPlaceholderTitle = computed(() => props.book.title ?? props.book.folderPath.split('/').pop() ?? null)
const hasCover = computed(() => props.book.coverSource !== null)
const { coverUrl } = useCoverVersions()
const coverSrc = computed(() => coverUrl(props.book.id, 'cover'))
const coverImageRatio = ref<number | null>(null)
watch(coverSrc, () => {
coverLoaded.value = false
@@ -277,8 +271,14 @@ watch(coverSrc, () => {
})
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const slotAspectRatio = computed(() => coverAspectRatioValue(String(coverAspectRatio.value)))
const fittedCoverSpineStyle = computed(() => fittedCoverFrameStyle(coverImageRatio.value, slotAspectRatio.value))
const { bookCoverDisplayMode } = useDisplaySettings()
const detailCoverAspectRatio = computed(() => {
if (bookCoverDisplayMode.value !== 'natural-bottom' || !hasCover.value || !coverLoaded.value || coverFailed.value || !coverImageRatio.value) {
return coverAspectRatio.value
}
return `${coverImageRatio.value} / 1`
})
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 readableFiles = computed(() => props.book.files.filter((f) => f.format && READER_OPENABLE_FORMATS.has(f.format)))
@@ -727,12 +727,16 @@ function handleDeleteFromMenu() {
promptDelete(props.book.id)
}
function handleCoverLoad(event: Event) {
const target = event.target as HTMLImageElement | null
if (target && target.naturalWidth > 0 && target.naturalHeight > 0) {
coverImageRatio.value = target.naturalWidth / target.naturalHeight
}
function handleCoverLoad(ratio: number | null) {
coverLoaded.value = true
coverFailed.value = false
coverImageRatio.value = ratio
}
function handleCoverError() {
coverLoaded.value = false
coverFailed.value = true
coverImageRatio.value = null
}
function handleCoverClick() {
@@ -912,37 +916,23 @@ watch(
class="book-cover-surface--spine-fitted relative w-full rounded-sm overflow-hidden"
:disable-spine="isPrimaryAudio"
:class="hasCover && coverLoaded && !coverFailed ? 'cursor-zoom-in' : ''"
:style="[{ aspectRatio: coverAspectRatio }, !hasCover || !coverLoaded || coverFailed ? coverStyle : {}]"
:style="{ aspectRatio: detailCoverAspectRatio }"
@click="handleCoverClick"
>
<img
v-if="hasCover && !coverFailed"
<BookCoverArtwork
:src="coverSrc"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-lg brightness-50 transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
aria-hidden="true"
/>
<img
v-if="hasCover && !coverFailed"
:src="coverSrc"
class="absolute inset-0 w-full h-full object-contain transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
:alt="book.title ?? ''"
@load="handleCoverLoad"
@error="coverFailed = true"
/>
<div
v-if="!isPrimaryAudio && hasCover && coverLoaded && !coverFailed"
class="book-cover-spine-layer absolute z-[3]"
:style="fittedCoverSpineStyle"
/>
<div v-if="hasCover && !coverLoaded && !coverFailed" class="absolute inset-0 animate-pulse bg-white/10" />
<BookCoverPlaceholder
v-if="!hasCover || coverFailed"
:has-cover="hasCover"
:title="coverPlaceholderTitle"
:author-line="book.authors.map((a) => a.name).join(', ') || null"
:is-audio="isPrimaryAudio"
:seed="coverSeed"
:alt="book.title ?? ''"
:frame-aspect-ratio="detailCoverAspectRatio"
loading="eager"
backdrop-class="blur-lg brightness-50"
:spine="!isPrimaryAudio"
@load="handleCoverLoad"
@error="handleCoverError"
/>
</BookCoverSurface>
</div>
@@ -1121,7 +1111,7 @@ watch(
class="book-cover-surface--spine-fitted group relative w-full rounded-sm overflow-hidden"
:disable-spine="isPrimaryAudio"
:class="hasCover && coverLoaded && !coverFailed ? 'cursor-zoom-in' : ''"
:style="[{ aspectRatio: coverAspectRatio }, !hasCover || !coverLoaded || coverFailed ? coverStyle : {}]"
:style="{ aspectRatio: detailCoverAspectRatio }"
@click="handleCoverClick"
>
<Tooltip>
@@ -1135,35 +1125,20 @@ watch(
</TooltipTrigger>
<TooltipContent>Edit cover</TooltipContent>
</Tooltip>
<!-- Blurred background fill for mismatched aspect ratios -->
<img
v-if="hasCover && !coverFailed"
<BookCoverArtwork
:src="coverSrc"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-lg brightness-50 transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
aria-hidden="true"
/>
<img
v-if="hasCover && !coverFailed"
:src="coverSrc"
class="absolute inset-0 w-full h-full object-contain transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
:alt="book.title ?? ''"
@load="handleCoverLoad"
@error="coverFailed = true"
/>
<div
v-if="!isPrimaryAudio && hasCover && coverLoaded && !coverFailed"
class="book-cover-spine-layer absolute z-[3]"
:style="fittedCoverSpineStyle"
/>
<div v-if="hasCover && !coverLoaded && !coverFailed" class="absolute inset-0 animate-pulse bg-white/10" />
<BookCoverPlaceholder
v-if="!hasCover || coverFailed"
:has-cover="hasCover"
:title="coverPlaceholderTitle"
:author-line="book.authors.map((a) => a.name).join(', ') || null"
:is-audio="isPrimaryAudio"
:seed="coverSeed"
:alt="book.title ?? ''"
:frame-aspect-ratio="detailCoverAspectRatio"
loading="eager"
backdrop-class="blur-lg brightness-50"
:spine="!isPrimaryAudio"
@load="handleCoverLoad"
@error="handleCoverError"
/>
</BookCoverSurface>
@@ -87,25 +87,26 @@ function mountDetails(book: BookDetail) {
props: { book },
global: {
stubs: {
BookCoverArtwork: false,
BookCoverSurface: false,
},
},
})
}
async function loadCoverImages(wrapper: ReturnType<typeof mountDetails>) {
async function loadCoverImages(wrapper: ReturnType<typeof mountDetails>, naturalWidth = 1000, naturalHeight = 1000) {
const imgs = wrapper.findAll(`img[alt="${wrapper.props('book').title}"]`)
expect(imgs.length).toBe(2)
for (const img of imgs) {
Object.defineProperty(img.element, 'naturalWidth', { configurable: true, value: 1000 })
Object.defineProperty(img.element, 'naturalHeight', { configurable: true, value: 1000 })
Object.defineProperty(img.element, 'naturalWidth', { configurable: true, value: naturalWidth })
Object.defineProperty(img.element, 'naturalHeight', { configurable: true, value: naturalHeight })
await img.trigger('load')
}
}
describe('DetailsTab cover surface', () => {
const { bookSpineOverlay } = useDisplaySettings()
const { bookSpineOverlay, bookCoverDisplayMode } = useDisplaySettings()
beforeEach(() => {
mocks.api.mockReset()
@@ -146,6 +147,7 @@ describe('DetailsTab cover surface', () => {
afterEach(() => {
bookSpineOverlay.value = 'off'
bookCoverDisplayMode.value = 'blurred-fit'
vi.unstubAllGlobals()
})
@@ -167,6 +169,18 @@ describe('DetailsTab cover surface', () => {
expect(spineLayers[0]!.attributes('style')).toContain('translateY(-50%)')
})
it('shrinks natural-bottom detail cover surfaces to the loaded cover ratio', async () => {
bookCoverDisplayMode.value = 'natural-bottom'
const wrapper = mountDetails(makeBook())
await flushPromises()
await loadCoverImages(wrapper, 1200, 600)
const surfaces = wrapper.findAll('.book-cover-surface')
expect(surfaces.length).toBe(2)
expect(surfaces.every((surface) => surface.attributes('style')?.includes('aspect-ratio: 2 / 1'))).toBe(true)
})
it('forces spine overlay off for audiobook details covers', async () => {
bookSpineOverlay.value = 'strong'
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import { Loader2 } from 'lucide-vue-next'
import { useCoverVersions } from '../../composables/useCoverVersions'
import BookCoverArtwork from '../BookCoverArtwork.vue'
import BookCoverImage from '../BookCoverImage.vue'
import BookCoverPlaceholder from '../BookCoverPlaceholder.vue'
import BookCoverSurface from '../BookCoverSurface.vue'
import { useRefreshingBooks } from '@/features/book/composables/useRefreshingBooks'
@@ -18,6 +19,8 @@ const { isRefreshing } = useRefreshingBooks()
const seed = computed(() => props.title ?? String(props.bookId))
const isRefreshingBook = computed(() => isRefreshing(props.bookId))
const { coverUrl } = useCoverVersions()
const thumbnailSrc = computed(() => coverUrl(props.bookId, 'thumbnail'))
const showPreview = ref(false)
const previewPos = ref({ x: 0, y: 0 })
let hoverTimer: ReturnType<typeof setTimeout> | null = null
@@ -50,6 +53,10 @@ function handleKeydown(event: KeyboardEvent) {
}
}
function handleCoverClick() {
emit('cover-click')
}
const adjustedLeft = computed(() => {
const previewWidth = 180
if (previewPos.value.x + previewWidth > window.innerWidth - 8) {
@@ -72,12 +79,21 @@ const adjustedTop = computed(() => {
tabindex="0"
role="button"
aria-label="View cover"
class="relative flex h-9 w-9 shrink-0 cursor-pointer overflow-hidden rounded-sm transition-opacity hover:opacity-80"
@click="emit('cover-click')"
class="book-cover-surface--spine-fitted relative flex h-9 w-9 shrink-0 cursor-pointer overflow-hidden rounded-sm transition-opacity hover:opacity-80"
@click="handleCoverClick"
@keydown="handleKeydown"
>
<BookCoverImage v-if="hasCover" :book-id="bookId" type="thumbnail" class="h-full w-full object-cover" />
<BookCoverPlaceholder v-else :title="title" :author-line="null" :is-audio="false" :seed="seed" class="h-full w-full" />
<BookCoverArtwork
:src="thumbnailSrc"
:has-cover="hasCover"
:title="title"
:author-line="null"
:is-audio="isAudio"
:seed="seed"
:alt="title ?? ''"
frame-aspect-ratio="1/1"
:spine="!isAudio"
/>
<div v-if="isRefreshingBook" class="absolute inset-0 flex items-center justify-center bg-black/50">
<Loader2 :size="12" class="animate-spin text-white" />
</div>
@@ -41,4 +41,14 @@ describe('fittedCoverFrameStyle', () => {
expect(style.transform).toBe('translateX(-50%)')
expect(parseFloat(style.width ?? '0')).toBeCloseTo(75, 2)
})
it('bottom-aligns wider images when requested', () => {
const style = fittedCoverFrameStyle(1, 2 / 3, 'bottom')
expect(style.width).toBe('100%')
expect(style.left).toBe('0')
expect(style.bottom).toBe('0')
expect(style.top).toBeUndefined()
expect(style.transform).toBeUndefined()
expect(parseFloat(style.height ?? '0')).toBeCloseTo(66.6667, 2)
})
})
@@ -15,7 +15,9 @@ export function coverAspectRatioValue(value: string): number {
return 2 / 3
}
export function fittedCoverFrameStyle(imageRatio: number | null, slotRatio: number): Record<string, string> {
export type FittedCoverFrameAlign = 'center' | 'bottom'
export function fittedCoverFrameStyle(imageRatio: number | null, slotRatio: number, align: FittedCoverFrameAlign = 'center'): Record<string, string> {
if (!imageRatio || imageRatio <= 0 || !slotRatio || slotRatio <= 0) {
return { inset: '0' }
}
@@ -26,13 +28,20 @@ export function fittedCoverFrameStyle(imageRatio: number | null, slotRatio: numb
if (imageRatio > slotRatio) {
const heightPercent = (slotRatio / imageRatio) * 100
return {
const style: Record<string, string> = {
width: '100%',
height: `${heightPercent}%`,
top: '50%',
left: '0',
transform: 'translateY(-50%)',
}
if (align === 'bottom') {
style.bottom = '0'
} else {
style.top = '50%'
style.transform = 'translateY(-50%)'
}
return style
}
const widthPercent = (imageRatio / slotRatio) * 100
@@ -3,6 +3,7 @@ import { BookOpen, Play } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
import BookCoverSurface from '@/features/book/components/BookCoverSurface.vue'
import { useCurrentlyReadingWidget } from '../../composables/useCurrentlyReadingWidget'
@@ -63,11 +64,17 @@ function continueReading(bookId: number, fileId: number | null, fileFormat: stri
@click="goToBook(book.bookId)"
>
<!-- Cover thumbnail -->
<BookCoverSurface size="mini" class="h-14 w-9 shrink-0 overflow-hidden rounded">
<img v-if="book.hasCover" :src="coverUrl(book.bookId)" :alt="book.title ?? 'Book cover'" class="h-full w-full object-cover" />
<div v-else class="flex h-full w-full items-center justify-center bg-muted text-muted-foreground">
<BookOpen :size="12" />
</div>
<BookCoverSurface size="mini" class="book-cover-surface--spine-fitted h-14 w-9 shrink-0 overflow-hidden rounded">
<BookCoverArtwork
:src="coverUrl(book.bookId)"
:has-cover="book.hasCover"
:title="book.title"
:author-line="book.authors.length > 0 ? book.authors.join(', ') : null"
:is-audio="false"
:seed="book.title ?? String(book.bookId)"
:alt="book.title ?? 'Book cover'"
frame-aspect-ratio="9/14"
/>
</BookCoverSurface>
<!-- Info -->
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { Highlighter, BookOpen, ExternalLink } from 'lucide-vue-next'
import { Highlighter, ExternalLink } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
import BookCoverSurface from '@/features/book/components/BookCoverSurface.vue'
import { useHighlightOfTheDayWidget } from '../../composables/useHighlightOfTheDayWidget'
@@ -48,12 +49,18 @@ function goToBook() {
"{{ data.text.length > 200 ? data.text.slice(0, 200) + '...' : data.text }}"
</blockquote>
<button class="flex cursor-pointer items-center gap-2 rounded-lg pb-1 pl-1 text-left transition-colors hover:bg-muted/40" @click="goToBook">
<BookCoverSurface v-if="data.hasCover" size="mini" class="h-9 w-6 shrink-0 overflow-hidden rounded">
<img :src="coverUrl(data.bookId)" :alt="data.bookTitle ?? 'Cover'" class="h-full w-full object-cover" />
<BookCoverSurface size="mini" class="book-cover-surface--spine-fitted h-9 w-6 shrink-0 overflow-hidden rounded">
<BookCoverArtwork
:src="coverUrl(data.bookId)"
:has-cover="data.hasCover"
:title="data.bookTitle"
:author-line="null"
:is-audio="false"
:seed="data.bookTitle ?? String(data.bookId)"
:alt="data.bookTitle ?? 'Cover'"
frame-aspect-ratio="2/3"
/>
</BookCoverSurface>
<div v-else class="flex h-8 w-5 shrink-0 items-center justify-center rounded bg-muted">
<BookOpen :size="10" class="text-muted-foreground" />
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-[12px] font-medium leading-tight">{{ data.bookTitle ?? 'Untitled' }}</p>
<p v-if="data.chapterTitle" class="truncate text-[11px] text-muted-foreground">{{ data.chapterTitle }}</p>
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { Clock, BookOpen, Play } from 'lucide-vue-next'
import { Clock, Play } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
import BookCoverSurface from '@/features/book/components/BookCoverSurface.vue'
import { useLongWaitWidget } from '../../composables/useLongWaitWidget'
@@ -60,13 +61,19 @@ function startReading() {
tag="button"
type="button"
size="mini"
class="h-24 w-18 shrink-0 cursor-pointer overflow-hidden rounded transition-opacity hover:opacity-80"
class="book-cover-surface--spine-fitted h-24 w-18 shrink-0 cursor-pointer overflow-hidden rounded transition-opacity hover:opacity-80"
@click="goToBook"
>
<img v-if="data.hasCover" :src="coverUrl(data.bookId)" :alt="data.title ?? 'Cover'" class="h-full w-full object-cover" />
<div v-else class="flex h-full w-full items-center justify-center bg-muted">
<BookOpen :size="14" class="text-muted-foreground" />
</div>
<BookCoverArtwork
:src="coverUrl(data.bookId)"
:has-cover="data.hasCover"
:title="data.title"
:author-line="null"
:is-audio="false"
:seed="data.title ?? String(data.bookId)"
:alt="data.title ?? 'Cover'"
frame-aspect-ratio="3/4"
/>
</BookCoverSurface>
<div class="min-w-0 text-left">
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { Gem, Star, BookOpen, BookMarked, Check } from 'lucide-vue-next'
import { Gem, Star, BookMarked, Check } from 'lucide-vue-next'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
import BookCoverSurface from '@/features/book/components/BookCoverSurface.vue'
import { useBookStatus } from '@/features/book/composables/useBookStatus'
import { useNeglectedGemsWidget } from '../../composables/useNeglectedGemsWidget'
@@ -65,13 +66,19 @@ async function addToQueue() {
tag="button"
type="button"
size="mini"
class="h-19 w-13 cursor-pointer overflow-hidden rounded transition-opacity hover:opacity-80"
class="book-cover-surface--spine-fitted h-19 w-13 cursor-pointer overflow-hidden rounded transition-opacity hover:opacity-80"
@click="goToBook"
>
<img v-if="currentGem.hasCover" :src="coverUrl(currentGem.bookId)" :alt="currentGem.title ?? 'Cover'" class="h-full w-full object-cover" />
<div v-else class="flex h-full w-full items-center justify-center bg-muted">
<BookOpen :size="14" class="text-muted-foreground" />
</div>
<BookCoverArtwork
:src="coverUrl(currentGem.bookId)"
:has-cover="currentGem.hasCover"
:title="currentGem.title"
:author-line="null"
:is-audio="false"
:seed="currentGem.title ?? String(currentGem.bookId)"
:alt="currentGem.title ?? 'Cover'"
frame-aspect-ratio="13/19"
/>
</BookCoverSurface>
<button class="max-w-full cursor-pointer truncate text-center text-xs font-semibold hover:underline" @click="goToBook">
{{ currentGem.title ?? 'Untitled' }}
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
const { smartScopeFilterExpanded } = useDisplaySettings()
const { prefs, setPreference } = useSeriesCollapsePreference()
const globalCollapseEnabled = computed(() => prefs.value?.global ?? false)
async function handleGlobalCollapseToggle(value: boolean) {
await setPreference('global', value)
}
</script>
<template>
<div>
<p class="settings-group-label">Library Behavior</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="flex items-center justify-between gap-3 px-4 py-3 md:px-5 md:py-3.5 bg-card">
<div class="min-w-0">
<p class="settings-label">Show filter preview by default</p>
<p class="settings-hint overflow-hidden text-ellipsis whitespace-nowrap md:whitespace-normal md:overflow-visible">
Expand the active filter and sort summary when opening a smartScope
</p>
</div>
<ToggleSwitch v-model="smartScopeFilterExpanded" />
</div>
<div class="flex items-center justify-between gap-3 px-4 py-3 md:px-5 md:py-3.5 bg-card">
<div class="min-w-0">
<p class="settings-label">Collapse series by default</p>
<p class="settings-hint overflow-hidden text-ellipsis whitespace-nowrap md:whitespace-normal md:overflow-visible">
Group books in the same series into a single card in library and collection views
</p>
</div>
<ToggleSwitch :model-value="globalCollapseEnabled" @update:model-value="handleGlobalCollapseToggle" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { Image } from 'lucide-vue-next'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import {
useDisplaySettings,
type BookCoverDisplayMode,
type BookShadowStrength,
type BookSpineOverlay,
type CardOverlayKey,
} from '@/composables/useDisplaySettings'
const { cardOverlays, bookSpineOverlay, 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' },
{ key: 'format', label: 'File format', hint: 'Color-coded EPUB, PDF, CBZ badge at bottom-right' },
{ key: 'rating', label: 'Rating', hint: 'Star rating indicator at bottom-left' },
{ key: 'read-status', label: 'Read status', hint: 'Color icon showing the current reading status at top-left' },
{ key: 'series-position', label: 'Series number', hint: 'Badge showing the book position in its series at top-right (e.g. #3, #1.5)' },
{ key: 'lock-status', label: 'Lock status', hint: 'Metadata lock icon at top-right - orange when locked, green when unlocked' },
]
const BOOK_SPINE_OPTIONS: { id: BookSpineOverlay; label: string; hint: string }[] = [
{ id: 'off', label: 'Off', hint: 'No spine/gloss effect on covers' },
{ id: 'subtle', label: 'Subtle', hint: 'Light spine and sheen, closer to the default look' },
{ id: 'strong', label: 'Strong', hint: 'More pronounced spine and gloss treatment' },
]
const BOOK_SHADOW_OPTIONS: { id: BookShadowStrength; label: string; hint: string }[] = [
{ id: 'default', label: 'Default', hint: 'Current elevation and depth' },
{ id: 'strong', label: 'Strong', hint: 'Heavier shelf-like drop shadow under covers' },
]
const BOOK_COVER_DISPLAY_OPTIONS: { id: BookCoverDisplayMode; label: string; hint: string }[] = [
{ id: 'blurred-fit', label: 'Blurred fit', hint: 'Show the full cover with a blurred fill behind it' },
{ id: 'fill-crop', label: 'Fill card', hint: 'Fill the entire slot by cropping edges when aspect ratios differ' },
{ id: 'natural-bottom', label: 'Natural bottom', hint: 'Keep the original cover ratio and anchor the cover to the bottom' },
]
function toggleOverlay(key: CardOverlayKey) {
const idx = cardOverlays.value.indexOf(key)
if (idx === -1) cardOverlays.value = [...cardOverlays.value, key]
else cardOverlays.value = cardOverlays.value.filter((k) => k !== key)
}
function setBookSpineOverlay(mode: BookSpineOverlay) {
bookSpineOverlay.value = mode
}
function setBookShadowStrength(mode: BookShadowStrength) {
bookShadowStrength.value = mode
}
function setBookCoverDisplayMode(mode: BookCoverDisplayMode) {
bookCoverDisplayMode.value = mode
}
</script>
<template>
<div>
<p class="settings-group-label">Book Covers</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border mb-4 shadow-xs">
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Cover display mode</p>
<p class="settings-hint">Controls how real covers fit inside book slots when aspect ratios differ</p>
</div>
<div class="mt-3 grid gap-2 md:grid-cols-3">
<button
v-for="opt in BOOK_COVER_DISPLAY_OPTIONS"
:key="opt.id"
class="rounded-md border px-3 py-2 text-left transition-colors"
:class="
bookCoverDisplayMode === opt.id
? 'border-primary bg-primary/8 text-primary'
: 'border-border text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground'
"
@click="setBookCoverDisplayMode(opt.id)"
>
<span class="flex items-center gap-1.5 text-xs font-semibold">
<Image :size="12" />
{{ opt.label }}
</span>
<span class="mt-0.5 block text-[11px] leading-snug opacity-80">{{ opt.hint }}</span>
</button>
</div>
</div>
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Book spine overlay</p>
<p class="settings-hint">Adds a stylized spine and gloss effect to cover cards</p>
</div>
<div class="mt-3 grid gap-2 sm:grid-cols-3">
<button
v-for="opt in BOOK_SPINE_OPTIONS"
:key="opt.id"
class="rounded-md border px-3 py-2 text-left transition-colors"
:class="
bookSpineOverlay === opt.id
? 'border-primary bg-primary/8 text-primary'
: 'border-border text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground'
"
@click="setBookSpineOverlay(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">Cover shadow strength</p>
<p class="settings-hint">Controls depth under covers across grid, list, table, and dashboard thumbnails</p>
</div>
<div class="mt-3 flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50 self-start">
<button
v-for="opt in BOOK_SHADOW_OPTIONS"
:key="opt.id"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="bookShadowStrength === opt.id ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="setBookShadowStrength(opt.id)"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<p class="settings-label">Card overlays</p>
<p class="settings-hint">Choose metadata shown directly on book cover cards</p>
</div>
<div v-for="opt in OVERLAY_OPTIONS" :key="opt.key" class="flex items-center justify-between gap-3 px-4 py-3 md:px-5 md:py-3.5 bg-card">
<div class="min-w-0">
<p class="settings-label">{{ opt.label }}</p>
<p class="settings-hint overflow-hidden text-ellipsis whitespace-nowrap md:whitespace-normal md:overflow-visible">{{ opt.hint }}</p>
</div>
<ToggleSwitch :model-value="cardOverlays.includes(opt.key)" @update:model-value="toggleOverlay(opt.key)" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,241 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Circle, Square } from 'lucide-vue-next'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { useDisplaySettings, type AuthorCoverShape, type CoverSizeScope } from '@/composables/useDisplaySettings'
const { portraitCoverSize, squareCoverSize, coverSizeScope, portraitGridGap, squareGridGap, authorCoverSize, authorCoverShape, tableZebraStriping } =
useDisplaySettings()
const syncModeEnabled = computed(() => coverSizeScope.value === 'synced')
function setCoverSizeScope(mode: CoverSizeScope) {
coverSizeScope.value = mode
}
function setAuthorCoverShape(shape: AuthorCoverShape) {
authorCoverShape.value = shape
}
function handlePortraitCoverSizeInput(event: Event) {
portraitCoverSize.value = Number((event.target as HTMLInputElement).value)
}
function handleSquareCoverSizeInput(event: Event) {
squareCoverSize.value = Number((event.target as HTMLInputElement).value)
}
function handlePortraitGridGapInput(event: Event) {
portraitGridGap.value = Number((event.target as HTMLInputElement).value)
}
function handleSquareGridGapInput(event: Event) {
squareGridGap.value = Number((event.target as HTMLInputElement).value)
}
function handleAuthorCoverSizeInput(event: Event) {
authorCoverSize.value = Number((event.target as HTMLInputElement).value)
}
</script>
<template>
<div class="space-y-6">
<div>
<p class="settings-group-label">Library Grid Layout</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Cover size behavior</p>
<p class="settings-hint">Control whether portrait/square sizes and grid spacing are shared across all views or kept per-view</p>
<p v-if="!syncModeEnabled" class="settings-hint mt-1">Per-view mode: adjust cover size and spacing from each view's Display panel.</p>
</div>
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50 self-start">
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="coverSizeScope === 'synced' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="setCoverSizeScope('synced')"
>
Sync all views
</button>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="coverSizeScope === 'per-view' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="setCoverSizeScope('per-view')"
>
Per-view sizes
</button>
</div>
</div>
<div
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card"
:class="{ 'opacity-60': !syncModeEnabled }"
>
<div>
<p class="settings-label">Portrait cover size</p>
<p class="settings-hint">Used for portrait libraries and views</p>
</div>
<div class="w-full md:w-72">
<div class="mb-1.5 flex items-center justify-between gap-3">
<span class="text-xs text-muted-foreground">Cover size</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ portraitCoverSize }}px</span>
</div>
<input
:value="portraitCoverSize"
type="range"
min="100"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
:disabled="!syncModeEnabled"
@input="handlePortraitCoverSizeInput"
/>
</div>
</div>
<div
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card"
:class="{ 'opacity-60': !syncModeEnabled }"
>
<div>
<p class="settings-label">Square cover size</p>
<p class="settings-hint">Used for square libraries and views</p>
</div>
<div class="w-full md:w-72">
<div class="mb-1.5 flex items-center justify-between gap-3">
<span class="text-xs text-muted-foreground">Cover size</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ squareCoverSize }}px</span>
</div>
<input
:value="squareCoverSize"
type="range"
min="100"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
:disabled="!syncModeEnabled"
@input="handleSquareCoverSizeInput"
/>
</div>
</div>
<div
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card"
:class="{ 'opacity-60': !syncModeEnabled }"
>
<div>
<p class="settings-label">Portrait grid spacing</p>
<p class="settings-hint">Gap between portrait covers</p>
</div>
<div class="w-full md:w-72">
<div class="mb-1.5 flex items-center justify-between gap-3">
<span class="text-xs text-muted-foreground">Grid spacing</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ portraitGridGap }}px</span>
</div>
<input
:value="portraitGridGap"
type="range"
min="4"
max="40"
step="4"
class="w-full accent-primary cursor-pointer"
:disabled="!syncModeEnabled"
@input="handlePortraitGridGapInput"
/>
</div>
</div>
<div
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card"
:class="{ 'opacity-60': !syncModeEnabled }"
>
<div>
<p class="settings-label">Square grid spacing</p>
<p class="settings-hint">Gap between square covers</p>
</div>
<div class="w-full md:w-72">
<div class="mb-1.5 flex items-center justify-between gap-3">
<span class="text-xs text-muted-foreground">Grid spacing</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ squareGridGap }}px</span>
</div>
<input
:value="squareGridGap"
type="range"
min="4"
max="40"
step="4"
class="w-full accent-primary cursor-pointer"
:disabled="!syncModeEnabled"
@input="handleSquareGridGapInput"
/>
</div>
</div>
</div>
</div>
<div>
<p class="settings-group-label">Author Grid</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Cover size</p>
<p class="settings-hint">Width of author covers in the grid</p>
</div>
<div class="w-full md:w-72">
<div class="mb-1.5 flex items-center justify-between gap-3">
<span class="text-xs text-muted-foreground">Cover size</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ authorCoverSize }}px</span>
</div>
<input
:value="authorCoverSize"
type="range"
min="100"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
@input="handleAuthorCoverSizeInput"
/>
</div>
</div>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Cover shape</p>
<p class="settings-hint">Shape of author covers in the grid</p>
</div>
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50 self-start">
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="authorCoverShape === 'circle' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="setAuthorCoverShape('circle')"
>
<Circle :size="12" /> Circle
</button>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="authorCoverShape === 'square' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="setAuthorCoverShape('square')"
>
<Square :size="12" /> Square
</button>
</div>
</div>
</div>
</div>
<div>
<p class="settings-group-label">List and Table Views</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="flex items-center justify-between gap-3 px-4 py-3 md:px-5 md:py-3.5 bg-card">
<div class="min-w-0">
<p class="settings-label">Zebra striping</p>
<p class="settings-hint overflow-hidden text-ellipsis whitespace-nowrap md:whitespace-normal md:overflow-visible">
Alternate row background colors for easier scanning
</p>
</div>
<ToggleSwitch v-model="tableZebraStriping" />
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed } from 'vue'
import { toast } from 'vue-sonner'
import { Cloud, Monitor } from 'lucide-vue-next'
import { getDisplayPreferencesSnapshot } from '@/composables/useDisplaySettings'
import { loadDisplaySettingsFromServer, seedDisplaySettingsToServer } from '@/composables/useDisplaySettingsSync'
import { loadFromServer, seedToServer } from '@/composables/useThemeSync'
import { useAuth } from '@/features/auth/composables/useAuth'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { api } from '@/lib/api'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const { user } = useAuth()
const { isDemoRestrictedAccount } = usePermissions()
const syncEnabled = computed(() => !isDemoRestrictedAccount.value && (user.value?.settings?.syncThemePreferences ?? false))
async function handleSetStorageMode(sync: boolean) {
if (!user.value || syncEnabled.value === sync) return
if (isDemoRestrictedAccount.value) {
toast.error('Demo-restricted account cannot change theme storage mode')
return
}
try {
const res = await api('/api/v1/users/me/theme-storage-mode', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sync }),
})
if (!res.ok) {
toast.error('Failed to update storage mode')
return
}
user.value = {
...user.value,
settings: { ...user.value.settings, syncThemePreferences: sync },
}
if (sync) {
const [themePrefsRes, displayPrefsRes] = await Promise.all([api('/api/v1/user-preferences/theme'), api('/api/v1/user-preferences/display')])
if (themePrefsRes.ok) {
const body = (await themePrefsRes.json()) as { settings: unknown }
if (body.settings == null) {
await seedToServer({
theme: themeStore.theme,
accent: themeStore.accent,
radius: themeStore.radius,
background: themeStore.background,
brightness: themeStore.brightness,
})
} else {
await loadFromServer()
}
}
if (displayPrefsRes.ok) {
const body = (await displayPrefsRes.json()) as { settings: unknown }
if (body.settings == null) {
await seedDisplaySettingsToServer(getDisplayPreferencesSnapshot())
} else {
await loadDisplaySettingsFromServer()
}
}
}
toast.success(sync ? 'Preferences will now be synced' : 'Preferences will stay on this device')
} catch {
toast.error('An error occurred while updating storage mode')
}
}
async function handleDeviceMode() {
await handleSetStorageMode(false)
}
async function handleAccountMode() {
await handleSetStorageMode(true)
}
</script>
<template>
<div>
<p class="settings-group-label">Where to save appearance preferences</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div
class="flex items-start gap-4 px-4 py-3.5 md:px-5 md:py-4 rounded-lg border-2 cursor-pointer transition-colors"
:class="!syncEnabled ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-muted-foreground/30'"
@click="handleDeviceMode"
>
<div
class="mt-0.5 flex items-center justify-center w-8 h-8 rounded-lg shrink-0 transition-colors"
:class="!syncEnabled ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
>
<Monitor :size="16" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="settings-label">This device only</span>
<span v-if="!syncEnabled" class="text-xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-primary/15 text-primary">
Active
</span>
</div>
<span class="block text-xs text-muted-foreground leading-relaxed">
Preferences stay in your browser. Best if you want a different look on different devices.
</span>
</div>
</div>
<div
class="flex items-start gap-4 px-4 py-3.5 md:px-5 md:py-4 rounded-lg border-2 transition-colors"
:class="[
isDemoRestrictedAccount ? 'border-border bg-card opacity-50 cursor-not-allowed' : 'cursor-pointer',
!isDemoRestrictedAccount && (syncEnabled ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-muted-foreground/30'),
]"
@click="handleAccountMode"
>
<div
class="mt-0.5 flex items-center justify-center w-8 h-8 rounded-lg shrink-0 transition-colors"
:class="syncEnabled ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
>
<Cloud :size="16" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="settings-label">My account</span>
<span v-if="syncEnabled" class="text-xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-primary/15 text-primary">
Active
</span>
<span
v-if="isDemoRestrictedAccount"
class="text-xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-muted text-muted-foreground"
>
Not available
</span>
</div>
<span class="block text-xs text-muted-foreground leading-relaxed">
Preferences are saved to your account. Best if you want the same appearance on every device.
</span>
</div>
</div>
</div>
</div>
</template>
@@ -1,152 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import { toast } from 'vue-sonner'
import { ACCENT_VIVID, ACCENT_PASTEL, BACKGROUND_OPTIONS, RADIUS_OPTIONS, useThemeStore } from '@/stores/theme'
import { Circle, Cloud, Monitor, Moon, Square, Sun } from 'lucide-vue-next'
import { loadFromServer, seedToServer } from '@/composables/useThemeSync'
import { useAuth } from '@/features/auth/composables/useAuth'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { api } from '@/lib/api'
import {
useDisplaySettings,
type BookShadowStrength,
type BookSpineOverlay,
type CardOverlayKey,
type CoverSizeScope,
} from '@/composables/useDisplaySettings'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ACCENT_PASTEL, ACCENT_VIVID, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import AppearanceBehaviorSettings from './AppearanceBehaviorSettings.vue'
import AppearanceBookCoverSettings from './AppearanceBookCoverSettings.vue'
import AppearanceLayoutSettings from './AppearanceLayoutSettings.vue'
import AppearanceThemeSettings from './AppearanceThemeSettings.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
import { APPEARANCE_TAB_LABELS, APPEARANCE_TABS, normalizeAppearanceTab, type AppearanceTab as Tab } from './lib/appearance-tabs'
const route = useRoute()
const router = useRouter()
const themeStore = useThemeStore()
const { user } = useAuth()
const { isDemoRestrictedAccount } = usePermissions()
const {
portraitCoverSize,
squareCoverSize,
coverSizeScope,
portraitGridGap,
squareGridGap,
cardOverlays,
smartScopeFilterExpanded,
authorCoverSize,
authorCoverShape,
tableZebraStriping,
bookSpineOverlay,
bookShadowStrength,
} = useDisplaySettings()
const { prefs, setPreference } = useSeriesCollapsePreference()
const globalCollapseEnabled = computed(() => prefs.value?.global ?? false)
const syncEnabled = computed(() => !isDemoRestrictedAccount.value && (user.value?.settings?.syncThemePreferences ?? false))
async function handleGlobalCollapseToggle(value: boolean) {
await setPreference('global', value)
}
async function handleSetStorageMode(sync: boolean) {
if (!user.value || syncEnabled.value === sync) return
if (isDemoRestrictedAccount.value) {
toast.error('Demo-restricted account cannot change theme storage mode')
return
}
try {
const res = await api('/api/v1/users/me/theme-storage-mode', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sync }),
})
if (!res.ok) {
toast.error('Failed to update storage mode')
return
}
user.value = {
...user.value,
settings: { ...user.value.settings, syncThemePreferences: sync },
}
if (sync) {
const prefsRes = await api('/api/v1/user-preferences/theme')
if (prefsRes.ok) {
const body = (await prefsRes.json()) as { settings: unknown }
if (body.settings === null) {
await seedToServer({
theme: themeStore.theme,
accent: themeStore.accent,
radius: themeStore.radius,
background: themeStore.background,
brightness: themeStore.brightness,
})
} else {
await loadFromServer()
}
}
}
toast.success(sync ? 'Preferences will now be synced' : 'Preferences will stay on this device')
} catch {
toast.error('An error occurred while updating storage mode')
}
}
const OVERLAY_OPTIONS: { key: CardOverlayKey; label: string; hint: string }[] = [
{ key: 'progress-bar', label: 'Progress bar', hint: 'Thin colored line along the bottom edge' },
{ key: 'format', label: 'File format', hint: 'Color-coded EPUB, PDF, CBZ badge at bottom-right' },
{ key: 'rating', label: 'Rating', hint: 'Star rating indicator at bottom-left' },
{ key: 'read-status', label: 'Read status', hint: 'Color icon showing the current reading status at top-left' },
{ key: 'series-position', label: 'Series number', hint: 'Badge showing the book position in its series at top-right (e.g. #3, #1.5)' },
{ key: 'lock-status', label: 'Lock status', hint: 'Metadata lock icon at top-right - orange when locked, green when unlocked' },
]
const BOOK_SPINE_OPTIONS: { id: BookSpineOverlay; label: string; hint: string }[] = [
{ id: 'off', label: 'Off', hint: 'No spine/gloss effect on covers' },
{ id: 'subtle', label: 'Subtle', hint: 'Light spine and sheen, closer to the default look' },
{ id: 'strong', label: 'Strong', hint: 'More pronounced spine and gloss treatment' },
]
const BOOK_SHADOW_OPTIONS: { id: BookShadowStrength; label: string; hint: string }[] = [
{ id: 'default', label: 'Default', hint: 'Current elevation and depth' },
{ id: 'strong', label: 'Strong', hint: 'Heavier shelf-like drop shadow under covers' },
]
const BACKGROUND_GROUPS: { label: string; ids: string[] }[] = [
{ label: 'Fundamental', ids: ['none', 'dots', 'cross', 'terminal', 'millimeter'] },
{ label: 'Structural', ids: ['blueprint', 'brushed', 'scanlines', 'vinyl', 'carbon', 'perforated'] },
{ label: 'Ambient', ids: ['aurora', 'horizon', 'glow', 'mesh', 'elevation'] },
{ label: 'Refractive', ids: ['prism', 'spectrum', 'spectrum-x', 'spectrum-plus', 'eclipse'] },
]
const activeTab = ref<Tab>(normalizeAppearanceTab(route.query.tab))
const tabs: { id: Tab; label: string }[] = APPEARANCE_TABS.map((id) => ({ id, label: APPEARANCE_TAB_LABELS[id] }))
const accentLabel = computed(() => [...ACCENT_VIVID, ...ACCENT_PASTEL].find((opt) => opt.id === themeStore.accent)?.label ?? themeStore.accent)
const backgroundLabel = computed(() => BACKGROUND_OPTIONS.find((opt) => opt.id === themeStore.background)?.label ?? themeStore.background)
function toggleOverlay(key: CardOverlayKey) {
const idx = cardOverlays.value.indexOf(key)
if (idx === -1) cardOverlays.value = [...cardOverlays.value, key]
else cardOverlays.value = cardOverlays.value.filter((k) => k !== key)
function syncTabFromRoute(value: unknown) {
const normalized = normalizeAppearanceTab(value)
activeTab.value = normalized
if (value !== normalized) {
router.replace({ name: 'settings-appearance', query: { ...route.query, tab: normalized } })
}
}
function setCoverSizeScope(mode: CoverSizeScope) {
coverSizeScope.value = mode
}
syncTabFromRoute(route.query.tab)
function setBookSpineOverlay(mode: BookSpineOverlay) {
bookSpineOverlay.value = mode
}
watch(
() => route.query.tab,
(value) => {
syncTabFromRoute(value)
},
)
function setBookShadowStrength(mode: BookShadowStrength) {
bookShadowStrength.value = mode
function selectTab(tab: Tab) {
activeTab.value = tab
router.replace({ name: 'settings-appearance', query: { ...route.query, tab } })
}
const syncModeEnabled = computed(() => coverSizeScope.value === 'synced')
</script>
<template>
<SettingsPageHeader title="Appearance" subtitle="Customize how the app looks and feels." />
<SettingsPageHeader title="Display" subtitle="Control themes, covers, layout, and how your library is presented." />
<div
class="md:hidden sticky top-0 z-20 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
>
@@ -155,523 +52,25 @@ const syncModeEnabled = computed(() => coverSizeScope.value === 'synced')
</p>
</div>
<!-- Preference storage -->
<div class="mt-5 mb-6">
<p class="settings-group-label">Where to save appearance preferences</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div
class="flex items-start gap-4 px-4 py-3.5 md:px-5 md:py-4 rounded-lg border-2 cursor-pointer transition-colors"
:class="!syncEnabled ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-muted-foreground/30'"
@click="handleSetStorageMode(false)"
>
<div
class="mt-0.5 flex items-center justify-center w-8 h-8 rounded-lg shrink-0 transition-colors"
:class="!syncEnabled ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
>
<Monitor :size="16" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="settings-label">This device only</span>
<span v-if="!syncEnabled" class="text-xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-primary/15 text-primary">
Active
</span>
</div>
<span class="block text-xs text-muted-foreground leading-relaxed">
Preferences stay in your browser. Best if you want a different look on different devices.
</span>
</div>
</div>
<div
class="flex items-start gap-4 px-4 py-3.5 md:px-5 md:py-4 rounded-lg border-2 transition-colors"
:class="[
isDemoRestrictedAccount ? 'border-border bg-card opacity-50 cursor-not-allowed' : 'cursor-pointer',
!isDemoRestrictedAccount && (syncEnabled ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-muted-foreground/30'),
]"
@click="handleSetStorageMode(true)"
>
<div
class="mt-0.5 flex items-center justify-center w-8 h-8 rounded-lg shrink-0 transition-colors"
:class="syncEnabled ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
>
<Cloud :size="16" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="settings-label">My account</span>
<span v-if="syncEnabled" class="text-xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-primary/15 text-primary">
Active
</span>
<span
v-if="isDemoRestrictedAccount"
class="text-xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-muted text-muted-foreground"
>
Not available
</span>
</div>
<span class="block text-xs text-muted-foreground leading-relaxed">
Preferences are saved to your account. Best if you want the same appearance on every device.
</span>
</div>
</div>
</div>
<div
class="flex gap-1 mb-5 md:mb-6 border-b border-border overflow-x-auto md:overflow-visible md:static sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 snap-x"
>
<button
v-for="tab in tabs"
:key="tab.id"
:data-testid="`appearance-tab-${tab.id}`"
class="px-3 py-3 md:py-2 text-sm font-medium shrink-0 border-b-2 -mb-px transition-colors snap-start"
:class="
activeTab === tab.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
"
@click="selectTab(tab.id)"
>
{{ tab.label }}
</button>
</div>
<!-- Theme & colors -->
<div class="mb-6">
<p class="settings-group-label">Theme</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<!-- Light / dark -->
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Color scheme</p>
<p class="settings-hint">Light or dark interface</p>
</div>
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50 self-start">
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="themeStore.theme === 'light' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="themeStore.theme === 'dark' && themeStore.toggleTheme()"
>
<Sun :size="12" /> Light
</button>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="themeStore.theme === 'dark' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="themeStore.theme === 'light' && themeStore.toggleTheme()"
>
<Moon :size="12" /> Dark
</button>
</div>
</div>
<!-- Accent color -->
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<p class="settings-label mb-0.5">Accent color</p>
<p class="text-xs text-muted-foreground mb-3">Controls highlights and interactive elements</p>
<div class="space-y-2">
<div class="flex items-center gap-1.5 flex-wrap">
<Tooltip v-for="opt in ACCENT_VIVID" :key="opt.id">
<TooltipTrigger as-child>
<button
class="w-7 h-7 md:w-5 md:h-5 rounded-full transition-all hover:scale-110 focus:outline-none shrink-0"
:style="{
backgroundColor: opt.color,
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
outlineOffset: '2px',
transform: themeStore.accent === opt.id ? 'scale(1.25)' : '',
}"
@click="themeStore.setAccent(opt.id)"
/>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
<div class="flex items-center gap-1.5 flex-wrap">
<Tooltip v-for="opt in ACCENT_PASTEL" :key="opt.id">
<TooltipTrigger as-child>
<button
class="w-7 h-7 md:w-5 md:h-5 rounded-full transition-all hover:scale-110 focus:outline-none shrink-0"
:style="{
backgroundColor: opt.color,
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
outlineOffset: '2px',
transform: themeStore.accent === opt.id ? 'scale(1.25)' : '',
}"
@click="themeStore.setAccent(opt.id)"
/>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<!-- Corner radius -->
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Corner radius</p>
<p class="settings-hint">Roundness of cards and UI elements</p>
</div>
<div class="flex items-center gap-1.5 self-start">
<button
v-for="opt in RADIUS_OPTIONS"
:key="opt.id"
class="h-7 px-3 text-xs border-2 transition-colors font-medium"
:style="{ borderRadius: opt.id === 'sharp' ? '2px' : opt.id === 'default' ? '6px' : opt.id === 'rounded' ? '14px' : '999px' }"
:class="
themeStore.radius === opt.id
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground'
"
@click="themeStore.setRadius(opt.id)"
>
{{ opt.label }}
</button>
</div>
</div>
<!-- Dark mode brightness -->
<div v-if="themeStore.theme === 'dark'" class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="mb-3">
<div class="flex items-center justify-between gap-3 mb-0.5">
<p class="settings-label">Surface brightness</p>
<div class="flex items-center gap-2">
<span class="settings-value md:hidden">{{ themeStore.brightness }}%</span>
<button
v-if="themeStore.brightness > 0"
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
@click="themeStore.setBrightness(0)"
>
Reset
</button>
</div>
</div>
<p class="settings-hint">Lighten dark mode surfaces</p>
<div>
<span class="settings-value hidden md:inline">{{ themeStore.brightness }}%</span>
</div>
</div>
<input
:value="themeStore.brightness"
@input="themeStore.setBrightness(Number(($event.target as HTMLInputElement).value))"
type="range"
min="0"
max="100"
step="5"
class="w-full accent-primary cursor-pointer"
/>
</div>
</div>
</div>
<!-- Library view -->
<div>
<p class="settings-group-label">Library View</p>
<!-- Cover styling -->
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border mb-4 shadow-xs">
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Book spine overlay</p>
<p class="settings-hint">Adds a stylized spine and gloss effect to cover cards</p>
</div>
<div class="mt-3 grid gap-2 sm:grid-cols-3">
<button
v-for="opt in BOOK_SPINE_OPTIONS"
:key="opt.id"
class="rounded-md border px-3 py-2 text-left transition-colors"
:class="
bookSpineOverlay === opt.id
? 'border-primary bg-primary/8 text-primary'
: 'border-border text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground'
"
@click="setBookSpineOverlay(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">Cover shadow strength</p>
<p class="settings-hint">Controls depth under covers across grid, list, table, and dashboard thumbnails</p>
</div>
<div class="mt-3 flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50 self-start">
<button
v-for="opt in BOOK_SHADOW_OPTIONS"
:key="opt.id"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="bookShadowStrength === opt.id ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="setBookShadowStrength(opt.id)"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
<!-- Background pattern -->
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border mb-4 shadow-xs">
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="mb-3">
<div>
<p class="settings-label">Background pattern</p>
<p class="settings-hint">Pattern shown behind the book grid</p>
</div>
</div>
<div class="space-y-5 md:space-y-6">
<div v-for="group in BACKGROUND_GROUPS" :key="group.label">
<p class="text-[10px] font-bold text-muted-foreground/80 uppercase tracking-widest mb-2.5 ml-0.5">{{ group.label }}</p>
<div
class="flex items-center gap-3 md:gap-4 overflow-x-auto md:overflow-visible md:flex-wrap pb-1 pt-0.5 px-0.5 md:pt-0 md:px-0 md:pb-0 no-scrollbar"
>
<Tooltip v-for="opt in BACKGROUND_OPTIONS.filter((o) => group.ids.includes(o.id))" :key="opt.id">
<TooltipTrigger as-child>
<button
type="button"
class="w-14 h-10 rounded overflow-hidden transition-all ring-2 focus:outline-none shrink-0"
:class="
themeStore.background === opt.id ? 'ring-primary shadow-xs shadow-primary/20' : 'ring-border hover:ring-muted-foreground/40'
"
@click="themeStore.setBackground(opt.id)"
>
<div class="w-full h-full bg-background [transform:translate(0)] pattern-preview" :class="opt.cssClass" />
</button>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<!-- Cover size behavior -->
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Cover size behavior</p>
<p class="settings-hint">Control whether portrait/square sizes and grid spacing are shared across all views or kept per-view</p>
<p v-if="!syncModeEnabled" class="settings-hint mt-1">Per-view mode: adjust cover size and spacing from each view’s Display panel.</p>
</div>
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50 self-start">
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="coverSizeScope === 'synced' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="setCoverSizeScope('synced')"
>
Sync all views
</button>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="coverSizeScope === 'per-view' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="setCoverSizeScope('per-view')"
>
Per-view sizes
</button>
</div>
</div>
<!-- Portrait cover size -->
<div
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card"
:class="{ 'opacity-60': !syncModeEnabled }"
>
<div>
<p class="settings-label">Portrait cover size</p>
<p class="settings-hint">Used for portrait libraries and views</p>
</div>
<div class="w-full md:w-72">
<div class="mb-1.5 flex items-center justify-between gap-3">
<span class="text-xs text-muted-foreground">Cover size</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ portraitCoverSize }}px</span>
</div>
<input
:value="portraitCoverSize"
@input="portraitCoverSize = Number(($event.target as HTMLInputElement).value)"
type="range"
min="100"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
:disabled="!syncModeEnabled"
/>
</div>
</div>
<!-- Square cover size -->
<div
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card"
:class="{ 'opacity-60': !syncModeEnabled }"
>
<div>
<p class="settings-label">Square cover size</p>
<p class="settings-hint">Used for square libraries and views</p>
</div>
<div class="w-full md:w-72">
<div class="mb-1.5 flex items-center justify-between gap-3">
<span class="text-xs text-muted-foreground">Cover size</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ squareCoverSize }}px</span>
</div>
<input
:value="squareCoverSize"
@input="squareCoverSize = Number(($event.target as HTMLInputElement).value)"
type="range"
min="100"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
:disabled="!syncModeEnabled"
/>
</div>
</div>
<!-- Portrait grid gap -->
<div
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card"
:class="{ 'opacity-60': !syncModeEnabled }"
>
<div>
<p class="settings-label">Portrait grid spacing</p>
<p class="settings-hint">Gap between portrait covers</p>
</div>
<div class="w-full md:w-72">
<div class="mb-1.5 flex items-center justify-between gap-3">
<span class="text-xs text-muted-foreground">Grid spacing</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ portraitGridGap }}px</span>
</div>
<input
:value="portraitGridGap"
@input="portraitGridGap = Number(($event.target as HTMLInputElement).value)"
type="range"
min="4"
max="40"
step="4"
class="w-full accent-primary cursor-pointer"
:disabled="!syncModeEnabled"
/>
</div>
</div>
<!-- Square grid gap -->
<div
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card"
:class="{ 'opacity-60': !syncModeEnabled }"
>
<div>
<p class="settings-label">Square grid spacing</p>
<p class="settings-hint">Gap between square covers</p>
</div>
<div class="w-full md:w-72">
<div class="mb-1.5 flex items-center justify-between gap-3">
<span class="text-xs text-muted-foreground">Grid spacing</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ squareGridGap }}px</span>
</div>
<input
:value="squareGridGap"
@input="squareGridGap = Number(($event.target as HTMLInputElement).value)"
type="range"
min="4"
max="40"
step="4"
class="w-full accent-primary cursor-pointer"
:disabled="!syncModeEnabled"
/>
</div>
</div>
</div>
<!-- Card overlays -->
<p class="settings-group-label mt-6">Author Grid</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border mb-6 shadow-xs">
<!-- Author cover size -->
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Cover size</p>
<p class="settings-hint">Width of author covers in the grid</p>
</div>
<div class="w-full md:w-72">
<div class="mb-1.5 flex items-center justify-between gap-3">
<span class="text-xs text-muted-foreground">Cover size</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ authorCoverSize }}px</span>
</div>
<input
:value="authorCoverSize"
@input="authorCoverSize = Number(($event.target as HTMLInputElement).value)"
type="range"
min="100"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
/>
</div>
</div>
<!-- Author cover shape -->
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Cover shape</p>
<p class="settings-hint">Shape of author covers in the grid</p>
</div>
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50 self-start">
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="authorCoverShape === 'circle' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="authorCoverShape = 'circle'"
>
<Circle :size="12" /> Circle
</button>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="authorCoverShape === 'square' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="authorCoverShape = 'square'"
>
<Square :size="12" /> Square
</button>
</div>
</div>
</div>
<!-- Card overlays -->
<p class="settings-group-label">Card Overlays</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div v-for="opt in OVERLAY_OPTIONS" :key="opt.key" class="flex items-center justify-between gap-3 px-4 py-3 md:px-5 md:py-3.5 bg-card">
<div class="min-w-0">
<p class="settings-label">{{ opt.label }}</p>
<p class="settings-hint overflow-hidden text-ellipsis whitespace-nowrap md:whitespace-normal md:overflow-visible">{{ opt.hint }}</p>
</div>
<ToggleSwitch :model-value="cardOverlays.includes(opt.key)" @update:model-value="toggleOverlay(opt.key)" />
</div>
</div>
</div>
<!-- Table View -->
<div class="mt-8">
<p class="settings-group-label">Table View</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="flex items-center justify-between gap-3 px-4 py-3 md:px-5 md:py-3.5 bg-card">
<div class="min-w-0">
<p class="settings-label">Zebra striping</p>
<p class="settings-hint overflow-hidden text-ellipsis whitespace-nowrap md:whitespace-normal md:overflow-visible">
Alternate row background colors for easier scanning
</p>
</div>
<ToggleSwitch v-model="tableZebraStriping" />
</div>
</div>
</div>
<!-- SmartScopes -->
<div class="mt-8">
<p class="settings-group-label">Smart Scopes</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="flex items-center justify-between gap-3 px-4 py-3 md:px-5 md:py-3.5 bg-card">
<div class="min-w-0">
<p class="settings-label">Show filter preview by default</p>
<p class="settings-hint overflow-hidden text-ellipsis whitespace-nowrap md:whitespace-normal md:overflow-visible">
Expand the active filter and sort summary when opening a smartScope
</p>
</div>
<ToggleSwitch v-model="smartScopeFilterExpanded" />
</div>
</div>
</div>
<!-- Series -->
<div class="mt-8">
<p class="settings-group-label">Series</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="flex items-center justify-between gap-3 px-4 py-3 md:px-5 md:py-3.5 bg-card">
<div class="min-w-0">
<p class="settings-label">Collapse series by default</p>
<p class="settings-hint overflow-hidden text-ellipsis whitespace-nowrap md:whitespace-normal md:overflow-visible">
Group books in the same series into a single card in library and collection views
</p>
</div>
<ToggleSwitch :model-value="globalCollapseEnabled" @update:model-value="handleGlobalCollapseToggle" />
</div>
</div>
</div>
<AppearanceThemeSettings v-if="activeTab === 'theme'" />
<AppearanceBookCoverSettings v-else-if="activeTab === 'book-covers'" />
<AppearanceLayoutSettings v-else-if="activeTab === 'layout'" />
<AppearanceBehaviorSettings v-else-if="activeTab === 'behavior'" />
</template>
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { Moon, Sun } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ACCENT_PASTEL, ACCENT_VIVID, BACKGROUND_OPTIONS, RADIUS_OPTIONS, useThemeStore } from '@/stores/theme'
import AppearancePreferenceStorage from './AppearancePreferenceStorage.vue'
const themeStore = useThemeStore()
const BACKGROUND_GROUPS: { label: string; ids: string[] }[] = [
{ label: 'Fundamental', ids: ['none', 'dots', 'cross', 'terminal', 'millimeter'] },
{ label: 'Structural', ids: ['blueprint', 'brushed', 'scanlines', 'vinyl', 'carbon', 'perforated'] },
{ label: 'Ambient', ids: ['aurora', 'horizon', 'glow', 'mesh', 'elevation'] },
{ label: 'Refractive', ids: ['prism', 'spectrum', 'spectrum-x', 'spectrum-plus', 'eclipse'] },
]
function handleLightTheme() {
if (themeStore.theme === 'dark') themeStore.toggleTheme()
}
function handleDarkTheme() {
if (themeStore.theme === 'light') themeStore.toggleTheme()
}
function resetBrightness() {
themeStore.setBrightness(0)
}
function handleBrightnessInput(event: Event) {
themeStore.setBrightness(Number((event.target as HTMLInputElement).value))
}
</script>
<template>
<div class="space-y-6">
<AppearancePreferenceStorage />
<div>
<p class="settings-group-label">Theme</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Color scheme</p>
<p class="settings-hint">Light or dark interface</p>
</div>
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50 self-start">
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="themeStore.theme === 'light' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="handleLightTheme"
>
<Sun :size="12" /> Light
</button>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="themeStore.theme === 'dark' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="handleDarkTheme"
>
<Moon :size="12" /> Dark
</button>
</div>
</div>
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<p class="settings-label mb-0.5">Accent color</p>
<p class="text-xs text-muted-foreground mb-3">Controls highlights and interactive elements</p>
<div class="space-y-2">
<div class="flex items-center gap-1.5 flex-wrap">
<Tooltip v-for="opt in ACCENT_VIVID" :key="opt.id">
<TooltipTrigger as-child>
<button
class="w-7 h-7 md:w-5 md:h-5 rounded-full transition-all hover:scale-110 focus:outline-none shrink-0"
:style="{
backgroundColor: opt.color,
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
outlineOffset: '2px',
transform: themeStore.accent === opt.id ? 'scale(1.25)' : '',
}"
@click="themeStore.setAccent(opt.id)"
/>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
<div class="flex items-center gap-1.5 flex-wrap">
<Tooltip v-for="opt in ACCENT_PASTEL" :key="opt.id">
<TooltipTrigger as-child>
<button
class="w-7 h-7 md:w-5 md:h-5 rounded-full transition-all hover:scale-110 focus:outline-none shrink-0"
:style="{
backgroundColor: opt.color,
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
outlineOffset: '2px',
transform: themeStore.accent === opt.id ? 'scale(1.25)' : '',
}"
@click="themeStore.setAccent(opt.id)"
/>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">Corner radius</p>
<p class="settings-hint">Roundness of cards and UI elements</p>
</div>
<div class="flex items-center gap-1.5 self-start">
<button
v-for="opt in RADIUS_OPTIONS"
:key="opt.id"
class="h-7 px-3 text-xs border-2 transition-colors font-medium"
:style="{ borderRadius: opt.id === 'sharp' ? '2px' : opt.id === 'default' ? '6px' : opt.id === 'rounded' ? '14px' : '999px' }"
:class="
themeStore.radius === opt.id
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground'
"
@click="themeStore.setRadius(opt.id)"
>
{{ opt.label }}
</button>
</div>
</div>
<div v-if="themeStore.theme === 'dark'" class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="mb-3">
<div class="flex items-center justify-between gap-3 mb-0.5">
<p class="settings-label">Surface brightness</p>
<div class="flex items-center gap-2">
<span class="settings-value md:hidden">{{ themeStore.brightness }}%</span>
<button
v-if="themeStore.brightness > 0"
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
@click="resetBrightness"
>
Reset
</button>
</div>
</div>
<p class="settings-hint">Lighten dark mode surfaces</p>
<div>
<span class="settings-value hidden md:inline">{{ themeStore.brightness }}%</span>
</div>
</div>
<input
:value="themeStore.brightness"
type="range"
min="0"
max="100"
step="5"
class="w-full accent-primary cursor-pointer"
@input="handleBrightnessInput"
/>
</div>
</div>
</div>
<div>
<p class="settings-group-label">Library Background</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="mb-3">
<div>
<p class="settings-label">Background pattern</p>
<p class="settings-hint">Pattern shown behind the book grid</p>
</div>
</div>
<div class="space-y-5 md:space-y-6">
<div v-for="group in BACKGROUND_GROUPS" :key="group.label">
<p class="text-[10px] font-bold text-muted-foreground/80 uppercase tracking-widest mb-2.5 ml-0.5">{{ group.label }}</p>
<div
class="flex items-center gap-3 md:gap-4 overflow-x-auto md:overflow-visible md:flex-wrap pb-1 pt-0.5 px-0.5 md:pt-0 md:px-0 md:pb-0 no-scrollbar"
>
<Tooltip v-for="opt in BACKGROUND_OPTIONS.filter((o) => group.ids.includes(o.id))" :key="opt.id">
<TooltipTrigger as-child>
<button
type="button"
class="w-14 h-10 rounded overflow-hidden transition-all ring-2 focus:outline-none shrink-0"
:class="
themeStore.background === opt.id ? 'ring-primary shadow-xs shadow-primary/20' : 'ring-border hover:ring-muted-foreground/40'
"
@click="themeStore.setBackground(opt.id)"
>
<div class="w-full h-full bg-background [transform:translate(0)] pattern-preview" :class="opt.cssClass" />
</button>
</TooltipTrigger>
<TooltipContent>{{ opt.label }}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -27,7 +27,7 @@ const sections = computed<Section[]>(() => {
result.push({ label: 'Libraries', routeName: 'settings-libraries' })
}
result.push({ label: 'Appearance', routeName: 'settings-appearance' })
result.push({ label: 'Display', routeName: 'settings-appearance' })
result.push({ label: 'Reader', routeName: 'settings-reader-general' })
if (su || perms.includes('manage_metadata_config')) {
@@ -8,10 +8,49 @@ const userState = {
}
const permissionState = { isDemo: false }
const routeState = { query: {} as Record<string, unknown> }
const apiMock = vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>>()
const loadFromServerMock = vi.fn<() => Promise<void>>()
const seedToServerMock = vi.fn<(...args: unknown[]) => Promise<void>>()
const loadDisplaySettingsFromServerMock = vi.fn<() => Promise<void>>()
const seedDisplaySettingsToServerMock = vi.fn<(...args: unknown[]) => Promise<void>>()
const routerReplaceMock = vi.fn<(location: unknown) => void>()
const displaySnapshot = {
portraitCoverSize: 180,
squareCoverSize: 180,
coverSizeScope: 'per-view',
gridGap: 28,
portraitGridGap: 12,
squareGridGap: 12,
viewMode: 'grid',
cardOverlays: [],
smartScopeFilterExpanded: false,
authorCoverSize: 120,
authorCoverShape: 'circle',
tableZebraStriping: true,
tableDensity: 'comfortable',
bookSpineOverlay: 'off',
bookShadowStrength: 'default',
bookCoverDisplayMode: 'blurred-fit',
}
const displayRefs = {
portraitCoverSize: ref(180),
squareCoverSize: ref(180),
coverSizeScope: ref('per-view'),
portraitGridGap: ref(12),
squareGridGap: ref(12),
cardOverlays: ref([]),
smartScopeFilterExpanded: ref(false),
authorCoverSize: ref(120),
authorCoverShape: ref('circle'),
tableZebraStriping: ref(true),
bookSpineOverlay: ref('off'),
bookShadowStrength: ref('default'),
bookCoverDisplayMode: ref('blurred-fit'),
}
const themeStore = {
theme: 'dark',
@@ -34,6 +73,11 @@ vi.mock('@/features/auth/composables/useAuth', () => ({
},
}))
vi.mock('vue-router', () => ({
useRoute: () => routeState,
useRouter: () => ({ replace: routerReplaceMock }),
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({
isDemoRestrictedAccount: computed(() => permissionState.isDemo),
@@ -49,29 +93,22 @@ vi.mock('@/composables/useThemeSync', () => ({
seedToServer: (...args: unknown[]) => seedToServerMock(...args),
}))
vi.mock('@/composables/useDisplaySettingsSync', () => ({
loadDisplaySettingsFromServer: () => loadDisplaySettingsFromServerMock(),
seedDisplaySettingsToServer: (...args: unknown[]) => seedDisplaySettingsToServerMock(...args),
}))
vi.mock('@/stores/theme', () => ({
ACCENT_VIVID: [{ id: 'blue', label: 'Blue', color: '#0000ff' }],
ACCENT_PASTEL: [{ id: 'grey', label: 'Grey', color: '#999999' }],
RADIUS_OPTIONS: [{ id: 'rounded', label: 'Rounded', className: 'rounded-lg' }],
BACKGROUND_OPTIONS: [{ id: 'vinyl', label: 'Vinyl', previewClass: 'bg-muted' }],
BACKGROUND_OPTIONS: [{ id: 'vinyl', label: 'Vinyl', cssClass: 'bg-muted' }],
useThemeStore: () => themeStore,
}))
vi.mock('@/composables/useDisplaySettings', () => ({
useDisplaySettings: () => ({
portraitCoverSize: ref(180),
squareCoverSize: ref(180),
coverSizeScope: ref('separate'),
portraitGridGap: ref(12),
squareGridGap: ref(12),
cardOverlays: ref([]),
smartScopeFilterExpanded: ref(false),
authorCoverSize: ref(120),
authorCoverShape: ref('portrait'),
tableZebraStriping: ref(true),
bookSpineOverlay: ref('off'),
bookShadowStrength: ref('default'),
}),
getDisplayPreferencesSnapshot: () => displaySnapshot,
useDisplaySettings: () => displayRefs,
}))
vi.mock('@/features/book/composables/useSeriesCollapsePreference', () => ({
@@ -109,7 +146,9 @@ describe('AppearanceSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
permissionState.isDemo = false
routeState.query = {}
userState.settings = { syncThemePreferences: false }
displayRefs.bookCoverDisplayMode.value = 'blurred-fit'
apiMock.mockResolvedValue({ ok: true, json: async () => ({ settings: null }) })
})
@@ -122,6 +161,49 @@ describe('AppearanceSettings', () => {
expect(deviceCard.text()).toContain('Active')
})
it('defaults to the theme tab and shows the persistent storage section above tabs', () => {
const wrapper = mountComponent()
expect(wrapper.findAll('.settings-group-label').map((label) => label.text())).toEqual([
'Where to save appearance preferences',
'Theme',
'Library Background',
])
expect(routerReplaceMock).toHaveBeenCalledWith({ name: 'settings-appearance', query: { tab: 'theme' } })
})
it('renders appearance tabs in the recommended order', () => {
const wrapper = mountComponent()
expect(['theme', 'book-covers', 'layout', 'behavior'].map((id) => wrapper.get(`[data-testid="appearance-tab-${id}"]`).text())).toEqual([
'Theme',
'Book Covers',
'Layout',
'Behavior',
])
})
it('switches tabs through the appearance query param', async () => {
const wrapper = mountComponent()
await wrapper.get('[data-testid="appearance-tab-layout"]').trigger('click')
expect(routerReplaceMock).toHaveBeenLastCalledWith({ name: 'settings-appearance', query: { tab: 'layout' } })
expect(wrapper.findAll('.settings-group-label').map((label) => label.text())).toEqual([
'Library Grid Layout',
'Author Grid',
'List and Table Views',
])
})
it('normalizes invalid appearance tabs back to theme', () => {
routeState.query = { tab: 'unknown' }
mountComponent()
expect(routerReplaceMock).toHaveBeenCalledWith({ name: 'settings-appearance', query: { tab: 'theme' } })
})
it('shows My account as active when theme sync is enabled', () => {
userState.settings = { syncThemePreferences: true }
const wrapper = mountComponent()
@@ -133,7 +215,10 @@ describe('AppearanceSettings', () => {
})
it('enabling account sync seeds current local preferences when no server row exists', async () => {
apiMock.mockResolvedValueOnce({ ok: true, json: async () => ({}) }).mockResolvedValueOnce({ ok: true, json: async () => ({ settings: null }) })
apiMock
.mockResolvedValueOnce({ ok: true, json: async () => ({}) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ settings: null }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ settings: null }) })
const wrapper = mountComponent()
const cards = wrapper.findAll('.rounded-lg.border-2')
@@ -146,6 +231,7 @@ describe('AppearanceSettings', () => {
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ sync: true }) }),
)
expect(apiMock).toHaveBeenNthCalledWith(2, '/api/v1/user-preferences/theme')
expect(apiMock).toHaveBeenNthCalledWith(3, '/api/v1/user-preferences/display')
expect(seedToServerMock).toHaveBeenCalledWith({
theme: 'dark',
accent: 'blue',
@@ -153,7 +239,9 @@ describe('AppearanceSettings', () => {
background: 'vinyl',
brightness: 35,
})
expect(seedDisplaySettingsToServerMock).toHaveBeenCalledWith(displaySnapshot)
expect(loadFromServerMock).not.toHaveBeenCalled()
expect(loadDisplaySettingsFromServerMock).not.toHaveBeenCalled()
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('Preferences will now be synced')
})
@@ -161,6 +249,7 @@ describe('AppearanceSettings', () => {
apiMock
.mockResolvedValueOnce({ ok: true, json: async () => ({}) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ settings: { theme: 'light' } }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ settings: { bookCoverDisplayMode: 'natural-bottom' } }) })
const wrapper = mountComponent()
const cards = wrapper.findAll('.rounded-lg.border-2')
@@ -168,7 +257,9 @@ describe('AppearanceSettings', () => {
await flushPromises()
expect(loadFromServerMock).toHaveBeenCalled()
expect(loadDisplaySettingsFromServerMock).toHaveBeenCalled()
expect(seedToServerMock).not.toHaveBeenCalled()
expect(seedDisplaySettingsToServerMock).not.toHaveBeenCalled()
})
it('switching back to This device only calls the storage mode endpoint with sync false', async () => {
@@ -209,4 +300,16 @@ describe('AppearanceSettings', () => {
expect(apiMock).not.toHaveBeenCalled()
expect(vi.mocked(toast.error)).toHaveBeenCalledWith('Demo-restricted account cannot change theme storage mode')
})
it('lets the user choose the natural-bottom cover display mode', async () => {
const wrapper = mountComponent()
await wrapper.get('[data-testid="appearance-tab-book-covers"]').trigger('click')
const naturalButton = wrapper.findAll('button').find((button) => button.text().includes('Natural bottom'))
expect(naturalButton).toBeDefined()
await naturalButton!.trigger('click')
expect(displayRefs.bookCoverDisplayMode.value).toBe('natural-bottom')
})
})
@@ -42,9 +42,9 @@ describe('SettingsHeader', () => {
})
describe('tab visibility', () => {
it('shows Appearance tab', () => {
it('shows Display tab', () => {
const labels = getTabLabels(mountHeader())
expect(labels).toContain('Appearance')
expect(labels).toContain('Display')
})
it('shows Reader tab', () => {
@@ -219,8 +219,8 @@ describe('SettingsHeader', () => {
describe('active state', () => {
it('active tab has border-primary class when route matches', () => {
const wrapper = mountHeader({ routeName: 'settings-appearance' })
const appearanceBtn = wrapper.findAll('button').find((b) => b.text() === 'Appearance')
expect(appearanceBtn?.classes()).toContain('border-primary')
const displayBtn = wrapper.findAll('button').find((b) => b.text() === 'Display')
expect(displayBtn?.classes()).toContain('border-primary')
})
it('inactive tabs have border-transparent class', () => {
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { APPEARANCE_TAB_LABELS, APPEARANCE_TAB_TITLE_LABELS, APPEARANCE_TABS, normalizeAppearanceTab } from '../appearance-tabs'
describe('appearance-tabs', () => {
it('keeps the public tab order stable', () => {
expect(APPEARANCE_TABS).toEqual(['theme', 'book-covers', 'layout', 'behavior'])
})
it('has nav and title labels for every tab', () => {
for (const tab of APPEARANCE_TABS) {
expect(APPEARANCE_TAB_LABELS[tab]).toBeTruthy()
expect(APPEARANCE_TAB_TITLE_LABELS[tab]).toBeTruthy()
}
})
it('normalizes invalid values to theme', () => {
expect(normalizeAppearanceTab('unknown')).toBe('theme')
expect(normalizeAppearanceTab(null)).toBe('theme')
expect(normalizeAppearanceTab(undefined)).toBe('theme')
expect(normalizeAppearanceTab({})).toBe('theme')
})
it('returns valid tabs unchanged', () => {
for (const tab of APPEARANCE_TABS) {
expect(normalizeAppearanceTab(tab)).toBe(tab)
}
})
})
@@ -0,0 +1,24 @@
export const APPEARANCE_TABS = ['theme', 'book-covers', 'layout', 'behavior'] as const
export type AppearanceTab = (typeof APPEARANCE_TABS)[number]
export const APPEARANCE_TAB_LABELS: Record<AppearanceTab, string> = {
theme: 'Theme',
'book-covers': 'Book Covers',
layout: 'Layout',
behavior: 'Behavior',
}
export const APPEARANCE_TAB_TITLE_LABELS: Record<AppearanceTab, string> = {
theme: 'Display',
'book-covers': 'Book Covers',
layout: 'Display Layout',
behavior: 'Library Behavior',
}
export function normalizeAppearanceTab(value: unknown): AppearanceTab {
if (typeof value === 'string' && APPEARANCE_TABS.includes(value as AppearanceTab)) {
return value as AppearanceTab
}
return 'theme'
}
+7 -1
View File
@@ -6,6 +6,7 @@ import { INTEGRATIONS_TAB_INFO, normalizeIntegrationsTab } from '@/features/sett
import { ADMIN_TAB_INFO, normalizeAdminTab } from '@/features/settings/lib/admin-tabs'
import { SYSTEM_TAB_INFO, normalizeSystemTab } from '@/features/settings/lib/system-tabs'
import { ACCOUNT_TAB_INFO, normalizeAccountTab } from '@/features/settings/lib/account-tabs'
import { APPEARANCE_TAB_TITLE_LABELS, normalizeAppearanceTab } from '@/features/settings/lib/appearance-tabs'
import { registerAuthGuard } from './guards/auth.guard'
import { registerRouteTitleHook } from './title-resolver'
@@ -32,6 +33,11 @@ function resolveReaderTitle(to: RouteLocationNormalizedLoaded): string {
return READER_TAB_TITLE_LABELS[tab]
}
function resolveAppearanceTitle(to: RouteLocationNormalizedLoaded): string {
const tab = normalizeAppearanceTab(to.query.tab)
return APPEARANCE_TAB_TITLE_LABELS[tab]
}
function resolveEmailTitle(to: RouteLocationNormalizedLoaded): string {
const tab = normalizeEmailTab(to.query.tab)
return `${EMAIL_TAB_LABELS[tab]} · Email`
@@ -103,7 +109,7 @@ export const routes: RouteRecordRaw[] = [
path: 'appearance',
name: 'settings-appearance',
component: () => import('@/features/settings/AppearanceSettings.vue'),
meta: { title: 'Appearance' },
meta: { title: resolveAppearanceTitle },
},
{
path: 'opds',
+42
View File
@@ -0,0 +1,42 @@
export const CARD_OVERLAY_KEYS = ["progress-bar", "format", "rating", "read-status", "lock-status", "series-position"] as const;
export type CardOverlayKey = (typeof CARD_OVERLAY_KEYS)[number];
export const COVER_SIZE_SCOPES = ["per-view", "synced"] as const;
export type CoverSizeScope = (typeof COVER_SIZE_SCOPES)[number];
export const BOOK_VIEW_MODES = ["grid", "list", "table"] as const;
export type BookViewMode = (typeof BOOK_VIEW_MODES)[number];
export const AUTHOR_COVER_SHAPES = ["square", "circle"] as const;
export type AuthorCoverShape = (typeof AUTHOR_COVER_SHAPES)[number];
export const TABLE_DENSITIES = ["compact", "comfortable", "roomy"] as const;
export type TableDensity = (typeof TABLE_DENSITIES)[number];
export const BOOK_SPINE_OVERLAYS = ["off", "subtle", "strong"] as const;
export type BookSpineOverlay = (typeof BOOK_SPINE_OVERLAYS)[number];
export const BOOK_SHADOW_STRENGTHS = ["default", "strong"] as const;
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 interface DisplayPreferences {
portraitCoverSize: number;
squareCoverSize: number;
coverSizeScope: CoverSizeScope;
gridGap: number;
portraitGridGap: number;
squareGridGap: number;
viewMode: BookViewMode;
cardOverlays: CardOverlayKey[];
smartScopeFilterExpanded: boolean;
authorCoverSize: number;
authorCoverShape: AuthorCoverShape;
tableZebraStriping: boolean;
tableDensity: TableDensity;
bookSpineOverlay: BookSpineOverlay;
bookShadowStrength: BookShadowStrength;
bookCoverDisplayMode: BookCoverDisplayMode;
}
+1
View File
@@ -1,6 +1,7 @@
export * from "./audit";
export * from "./auth";
export * from "./theme";
export * from "./display-preferences";
export * from "./permissions";
export * from "./content-filter";
export * from "./metadata-preferences";
@@ -1,5 +1,5 @@
import type { Mocked } from 'vitest';
import { EMPTY_CONTENT_FILTER_RULES, type ThemePreferences } from '@bookorbit/types';
import { EMPTY_CONTENT_FILTER_RULES, type DisplayPreferences, type ThemePreferences } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { UserPreferencesController } from './user-preferences.controller';
@@ -32,6 +32,25 @@ const validThemePreferences: ThemePreferences = {
brightness: 35,
};
const validDisplayPreferences: DisplayPreferences = {
portraitCoverSize: 180,
squareCoverSize: 160,
coverSizeScope: 'per-view',
gridGap: 28,
portraitGridGap: 24,
squareGridGap: 20,
viewMode: 'grid',
cardOverlays: ['progress-bar', 'format', 'rating'],
smartScopeFilterExpanded: true,
authorCoverSize: 140,
authorCoverShape: 'circle',
tableZebraStriping: false,
tableDensity: 'comfortable',
bookSpineOverlay: 'subtle',
bookShadowStrength: 'strong',
bookCoverDisplayMode: 'natural-bottom',
};
describe('UserPreferencesController', () => {
let service: Mocked<UserPreferencesService>;
let controller: UserPreferencesController;
@@ -39,7 +58,9 @@ describe('UserPreferencesController', () => {
beforeEach(() => {
service = {
getThemePreferences: vi.fn(),
getDisplayPreferences: vi.fn(),
upsertThemePreferences: vi.fn(),
upsertDisplayPreferences: vi.fn(),
} as unknown as Mocked<UserPreferencesService>;
controller = new UserPreferencesController(service);
@@ -73,4 +94,33 @@ describe('UserPreferencesController', () => {
expect(service.upsertThemePreferences).toHaveBeenCalledWith(99, validThemePreferences);
});
it('GET /display returns null settings when no saved preferences exist', async () => {
service.getDisplayPreferences.mockResolvedValueOnce(null);
await expect(controller.getDisplayPreferences(makeUser({ id: 11 }))).resolves.toEqual({ settings: null });
expect(service.getDisplayPreferences).toHaveBeenCalledWith(11);
});
it('GET /display returns saved settings when present', async () => {
service.getDisplayPreferences.mockResolvedValueOnce(validDisplayPreferences);
await expect(controller.getDisplayPreferences(makeUser())).resolves.toEqual({ settings: validDisplayPreferences });
});
it('PUT /display delegates valid payloads to the service and returns 204', async () => {
const user = makeUser({ id: 42 });
const dto = { settings: validDisplayPreferences as unknown as Record<string, unknown> };
await expect(controller.upsertDisplayPreferences(dto, user)).resolves.toBeUndefined();
expect(service.upsertDisplayPreferences).toHaveBeenCalledWith(42, validDisplayPreferences);
});
it('PUT /display forwards the current user id to the service', async () => {
const user = makeUser({ id: 99 });
await controller.upsertDisplayPreferences({ settings: validDisplayPreferences as unknown as Record<string, unknown> }, user);
expect(service.upsertDisplayPreferences).toHaveBeenCalledWith(99, validDisplayPreferences);
});
});
@@ -15,9 +15,21 @@ export class UserPreferencesController {
return { settings };
}
@Get('display')
async getDisplayPreferences(@CurrentUser() user: RequestUser) {
const settings = await this.userPreferencesService.getDisplayPreferences(user.id);
return { settings };
}
@Put('theme')
@HttpCode(204)
async upsertThemePreferences(@Body() dto: UpsertUserPreferenceDto, @CurrentUser() user: RequestUser) {
await this.userPreferencesService.upsertThemePreferences(user.id, dto.settings);
}
@Put('display')
@HttpCode(204)
async upsertDisplayPreferences(@Body() dto: UpsertUserPreferenceDto, @CurrentUser() user: RequestUser) {
await this.userPreferencesService.upsertDisplayPreferences(user.id, dto.settings);
}
}
@@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ThemePreferences } from '@bookorbit/types';
import type { DisplayPreferences, ThemePreferences } from '@bookorbit/types';
import { UserPreferencesRepository } from './user-preferences.repository';
import { UserPreferencesService } from './user-preferences.service';
@@ -13,8 +13,27 @@ const validThemePreferences: ThemePreferences = {
brightness: 35,
};
const validDisplayPreferences: DisplayPreferences = {
portraitCoverSize: 180,
squareCoverSize: 160,
coverSizeScope: 'per-view',
gridGap: 28,
portraitGridGap: 24,
squareGridGap: 20,
viewMode: 'grid',
cardOverlays: ['progress-bar', 'format', 'rating'],
smartScopeFilterExpanded: true,
authorCoverSize: 140,
authorCoverShape: 'circle',
tableZebraStriping: false,
tableDensity: 'comfortable',
bookSpineOverlay: 'subtle',
bookShadowStrength: 'strong',
bookCoverDisplayMode: 'natural-bottom',
};
const repo = {
findByCategory: vi.fn<(...args: [number, string]) => Promise<{ data: ThemePreferences } | undefined>>(),
findByCategory: vi.fn<(...args: [number, string]) => Promise<{ data: ThemePreferences | DisplayPreferences } | undefined>>(),
upsert: vi.fn<(...args: [number, string, Record<string, unknown>]) => Promise<void>>(),
delete: vi.fn<(...args: [number, string]) => Promise<void>>(),
};
@@ -40,6 +59,17 @@ describe('UserPreferencesService', () => {
await expect(service.getThemePreferences(7)).resolves.toEqual(validThemePreferences);
});
it('getDisplayPreferences returns null when repository has no row', async () => {
await expect(service.getDisplayPreferences(7)).resolves.toBeNull();
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'display');
});
it('getDisplayPreferences returns saved display settings when row exists', async () => {
repo.findByCategory.mockResolvedValueOnce({ data: validDisplayPreferences });
await expect(service.getDisplayPreferences(7)).resolves.toEqual(validDisplayPreferences);
});
it('upsertThemePreferences persists validated settings', async () => {
await expect(service.upsertThemePreferences(11, validThemePreferences)).resolves.toBeUndefined();
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', validThemePreferences);
@@ -97,4 +127,96 @@ describe('UserPreferencesService', () => {
await expect(service.upsertThemePreferences(11, incomplete)).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertThemePreferences rethrows repository failures after validation', async () => {
const err = new Error('database unavailable');
repo.upsert.mockRejectedValueOnce(err);
await expect(service.upsertThemePreferences(11, validThemePreferences)).rejects.toBe(err);
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', validThemePreferences);
});
it('upsertThemePreferences logs and rethrows non-Error repository failures', async () => {
const err = 'database unavailable';
repo.upsert.mockRejectedValueOnce(err);
await expect(service.upsertThemePreferences(11, validThemePreferences)).rejects.toBe(err);
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', validThemePreferences);
});
it('upsertDisplayPreferences persists validated settings', async () => {
await expect(service.upsertDisplayPreferences(11, validDisplayPreferences as unknown as Record<string, unknown>)).resolves.toBeUndefined();
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', validDisplayPreferences);
});
it('upsertDisplayPreferences rejects invalid cover display modes', async () => {
await expect(
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, bookCoverDisplayMode: 'stretched' } as never),
).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertDisplayPreferences rejects invalid enum values', async () => {
await expect(service.upsertDisplayPreferences(11, { ...validDisplayPreferences, tableDensity: 'huge' } as never)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertDisplayPreferences rejects cover sizes below bounds', async () => {
await expect(service.upsertDisplayPreferences(11, { ...validDisplayPreferences, portraitCoverSize: 99 })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertDisplayPreferences rejects grid gaps above bounds', async () => {
await expect(service.upsertDisplayPreferences(11, { ...validDisplayPreferences, squareGridGap: 81 })).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertDisplayPreferences rejects duplicate card overlays', async () => {
await expect(service.upsertDisplayPreferences(11, { ...validDisplayPreferences, cardOverlays: ['format', 'format'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertDisplayPreferences rejects unknown card overlays', async () => {
await expect(
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, cardOverlays: ['format', 'provider'] } as never),
).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertDisplayPreferences rejects extra unknown fields', async () => {
await expect(
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, unexpected: true } as Record<string, unknown>),
).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertDisplayPreferences rejects payloads missing required fields', async () => {
const { bookCoverDisplayMode, ...incomplete } = validDisplayPreferences;
void bookCoverDisplayMode;
await expect(service.upsertDisplayPreferences(11, incomplete as unknown as Record<string, unknown>)).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertDisplayPreferences rethrows repository failures after validation', async () => {
const err = new Error('database unavailable');
repo.upsert.mockRejectedValueOnce(err);
await expect(service.upsertDisplayPreferences(11, validDisplayPreferences as unknown as Record<string, unknown>)).rejects.toBe(err);
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', validDisplayPreferences);
});
it('upsertDisplayPreferences logs and rethrows non-Error repository failures', async () => {
const err = 'database unavailable';
repo.upsert.mockRejectedValueOnce(err);
await expect(service.upsertDisplayPreferences(11, validDisplayPreferences as unknown as Record<string, unknown>)).rejects.toBe(err);
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', validDisplayPreferences);
});
});
@@ -1,4 +1,19 @@
import { ACCENT_IDS, BACKGROUND_IDS, RADIUS_IDS, THEME_IDS, type ThemePreferences } from '@bookorbit/types';
import {
ACCENT_IDS,
AUTHOR_COVER_SHAPES,
BACKGROUND_IDS,
BOOK_COVER_DISPLAY_MODES,
BOOK_SHADOW_STRENGTHS,
BOOK_SPINE_OVERLAYS,
BOOK_VIEW_MODES,
CARD_OVERLAY_KEYS,
COVER_SIZE_SCOPES,
RADIUS_IDS,
TABLE_DENSITIES,
THEME_IDS,
type DisplayPreferences,
type ThemePreferences,
} from '@bookorbit/types';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { z } from 'zod';
@@ -15,6 +30,36 @@ const THEME_PREFERENCES_SCHEMA = z
})
.strict();
const DISPLAY_PREFERENCES_SCHEMA = z
.object({
portraitCoverSize: z.number().int().min(100).max(400),
squareCoverSize: z.number().int().min(100).max(400),
coverSizeScope: z.enum(COVER_SIZE_SCOPES),
gridGap: z.number().int().min(1).max(80),
portraitGridGap: z.number().int().min(1).max(80),
squareGridGap: z.number().int().min(1).max(80),
viewMode: z.enum(BOOK_VIEW_MODES),
cardOverlays: z.array(z.enum(CARD_OVERLAY_KEYS)),
smartScopeFilterExpanded: z.boolean(),
authorCoverSize: z.number().int().min(100).max(400),
authorCoverShape: z.enum(AUTHOR_COVER_SHAPES),
tableZebraStriping: z.boolean(),
tableDensity: z.enum(TABLE_DENSITIES),
bookSpineOverlay: z.enum(BOOK_SPINE_OVERLAYS),
bookShadowStrength: z.enum(BOOK_SHADOW_STRENGTHS),
bookCoverDisplayMode: z.enum(BOOK_COVER_DISPLAY_MODES),
})
.strict()
.superRefine((data, ctx) => {
if (new Set(data.cardOverlays).size !== data.cardOverlays.length) {
ctx.addIssue({
code: 'custom',
path: ['cardOverlays'],
message: 'cardOverlays must not contain duplicate values',
});
}
});
@Injectable()
export class UserPreferencesService {
private readonly logger = new Logger(UserPreferencesService.name);
@@ -26,6 +71,11 @@ export class UserPreferencesService {
return row ? (row.data as ThemePreferences) : null;
}
async getDisplayPreferences(userId: number): Promise<DisplayPreferences | null> {
const row = await this.repo.findByCategory(userId, 'display');
return row ? (row.data as DisplayPreferences) : null;
}
async upsertThemePreferences(userId: number, data: Record<string, unknown>): Promise<void> {
const start = Date.now();
this.logger.log(`[user_preferences.upsert_theme] [start] userId=${userId} - upsert theme preferences started`);
@@ -52,4 +102,31 @@ export class UserPreferencesService {
throw err;
}
}
async upsertDisplayPreferences(userId: number, data: Record<string, unknown>): Promise<void> {
const start = Date.now();
this.logger.log(`[user_preferences.upsert_display] [start] userId=${userId} - upsert display preferences started`);
const result = DISPLAY_PREFERENCES_SCHEMA.safeParse(data);
if (!result.success) {
const firstIssue = result.error.issues[0];
const issuePath = firstIssue?.path.length ? firstIssue.path.join('.') : 'settings';
const issueMessage = firstIssue?.message ?? 'Invalid settings payload';
throw new BadRequestException(`Invalid display preferences at "${issuePath}": ${issueMessage}`);
}
try {
await this.repo.upsert(userId, 'display', result.data);
const durationMs = Date.now() - start;
this.logger.log(`[user_preferences.upsert_display] [end] userId=${userId} durationMs=${durationMs} - upsert display preferences completed`);
} catch (err) {
const durationMs = Date.now() - start;
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.error(
`[user_preferences.upsert_display] [fail] userId=${userId} durationMs=${durationMs} errorClass=${errorClass} error="${error}" - upsert display preferences failed`,
);
throw err;
}
}
}