mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user