mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(settings): add option to show titles under grid cards (#181)
Add a user preference to configure the label displayed underneath book and series cards in grid view. The option allows users to select between hiding labels completely, showing book titles, or showing series titles. Closes #159
This commit is contained in:
@@ -21,6 +21,8 @@ function resetDisplaySettings() {
|
||||
settings.bookShadowStrength.value = 'default'
|
||||
settings.bookCoverDisplayMode.value = 'blurred-fit'
|
||||
settings.seriesCardCoverMode.value = 'mosaic'
|
||||
settings.gridCardPrimaryLabel.value = 'hidden'
|
||||
settings.gridCardSecondaryLabel.value = 'hidden'
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -115,4 +117,45 @@ describe('useDisplaySettings preferences helpers', () => {
|
||||
resetDisplaySettings()
|
||||
expect(settings.seriesCardCoverMode.value).toBe('mosaic')
|
||||
})
|
||||
|
||||
it('includes gridCardPrimaryLabel and gridCardSecondaryLabel in snapshot', () => {
|
||||
settings.gridCardPrimaryLabel.value = 'book-title'
|
||||
settings.gridCardSecondaryLabel.value = 'author'
|
||||
const snap = getDisplayPreferencesSnapshot()
|
||||
expect(snap.gridCardPrimaryLabel).toBe('book-title')
|
||||
expect(snap.gridCardSecondaryLabel).toBe('author')
|
||||
})
|
||||
|
||||
it('defaults both label fields to hidden', () => {
|
||||
resetDisplaySettings()
|
||||
expect(settings.gridCardPrimaryLabel.value).toBe('hidden')
|
||||
expect(settings.gridCardSecondaryLabel.value).toBe('hidden')
|
||||
})
|
||||
|
||||
it('sanitizes valid gridCardPrimaryLabel values', () => {
|
||||
for (const value of ['hidden', 'book-title', 'series-title', 'series-title-position', 'author'] as const) {
|
||||
expect(sanitizeDisplayPreferences({ gridCardPrimaryLabel: value })).toEqual({ gridCardPrimaryLabel: value })
|
||||
}
|
||||
})
|
||||
|
||||
it('sanitizes valid gridCardSecondaryLabel values', () => {
|
||||
for (const value of ['hidden', 'book-title', 'series-title', 'series-title-position', 'author'] as const) {
|
||||
expect(sanitizeDisplayPreferences({ gridCardSecondaryLabel: value })).toEqual({ gridCardSecondaryLabel: value })
|
||||
}
|
||||
})
|
||||
|
||||
it('drops invalid gridCardPrimaryLabel values', () => {
|
||||
expect(sanitizeDisplayPreferences({ gridCardPrimaryLabel: 'unknown-field' })).toEqual({})
|
||||
expect(sanitizeDisplayPreferences({ gridCardPrimaryLabel: 42 })).toEqual({})
|
||||
})
|
||||
|
||||
it('drops invalid gridCardSecondaryLabel values', () => {
|
||||
expect(sanitizeDisplayPreferences({ gridCardSecondaryLabel: 'bad-value' })).toEqual({})
|
||||
})
|
||||
|
||||
it('applies gridCardPrimaryLabel and gridCardSecondaryLabel from preferences', () => {
|
||||
applyDisplayPreferences({ gridCardPrimaryLabel: 'series-title', gridCardSecondaryLabel: 'author' })
|
||||
expect(settings.gridCardPrimaryLabel.value).toBe('series-title')
|
||||
expect(settings.gridCardSecondaryLabel.value).toBe('author')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,10 @@ function validDisplayPreferences(overrides: Partial<DisplayPreferences> = {}): D
|
||||
bookSpineOverlay: 'subtle',
|
||||
bookShadowStrength: 'strong',
|
||||
bookCoverDisplayMode: 'blurred-fit',
|
||||
cardInfoMode: 'hover-overlay',
|
||||
seriesCardCoverMode: 'mosaic',
|
||||
gridCardPrimaryLabel: 'hidden',
|
||||
gridCardSecondaryLabel: 'hidden',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -252,4 +255,56 @@ describe('useDisplaySettingsSync', () => {
|
||||
|
||||
expect(displaySettings.useDisplaySettings().seriesCardCoverMode.value).toBe('latest-volume')
|
||||
})
|
||||
|
||||
it('syncs gridCardPrimaryLabel changes to the server', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { apiMock, displaySettings, sync } = await loadModules()
|
||||
apiMock.mockResolvedValue({ ok: true })
|
||||
|
||||
sync.initDisplaySettingsSync()
|
||||
displaySettings.useDisplaySettings().gridCardPrimaryLabel.value = 'book-title'
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
'/api/v1/user-preferences/display',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: expect.stringContaining('"gridCardPrimaryLabel":"book-title"'),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('syncs gridCardSecondaryLabel changes to the server', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { apiMock, displaySettings, sync } = await loadModules()
|
||||
apiMock.mockResolvedValue({ ok: true })
|
||||
|
||||
sync.initDisplaySettingsSync()
|
||||
displaySettings.useDisplaySettings().gridCardSecondaryLabel.value = 'author'
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
'/api/v1/user-preferences/display',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: expect.stringContaining('"gridCardSecondaryLabel":"author"'),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('loads gridCardPrimaryLabel and gridCardSecondaryLabel from the server', async () => {
|
||||
const { apiMock, displaySettings, sync } = await loadModules()
|
||||
apiMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
settings: validDisplayPreferences({ gridCardPrimaryLabel: 'series-title', gridCardSecondaryLabel: 'author' }),
|
||||
}),
|
||||
})
|
||||
|
||||
await sync.loadDisplaySettingsFromServer()
|
||||
|
||||
const s = displaySettings.useDisplaySettings()
|
||||
expect(s.gridCardPrimaryLabel.value).toBe('series-title')
|
||||
expect(s.gridCardSecondaryLabel.value).toBe('author')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
BOOK_SHADOW_STRENGTHS,
|
||||
BOOK_SPINE_OVERLAYS,
|
||||
BOOK_VIEW_MODES,
|
||||
CARD_INFO_MODES,
|
||||
CARD_OVERLAY_KEYS,
|
||||
COVER_SIZE_SCOPES,
|
||||
GRID_CARD_LABEL_FIELDS,
|
||||
SERIES_CARD_COVER_MODES,
|
||||
TABLE_DENSITIES,
|
||||
type AuthorCoverShape,
|
||||
@@ -14,9 +16,11 @@ import {
|
||||
type BookShadowStrength,
|
||||
type BookSpineOverlay,
|
||||
type BookViewMode,
|
||||
type CardInfoMode,
|
||||
type CardOverlayKey,
|
||||
type CoverSizeScope,
|
||||
type DisplayPreferences,
|
||||
type GridCardLabelField,
|
||||
type SeriesCardCoverMode,
|
||||
type TableDensity,
|
||||
} from '@bookorbit/types'
|
||||
@@ -28,9 +32,11 @@ export type {
|
||||
BookShadowStrength,
|
||||
BookSpineOverlay,
|
||||
BookViewMode,
|
||||
CardInfoMode,
|
||||
CardOverlayKey,
|
||||
CoverSizeScope,
|
||||
DisplayPreferences,
|
||||
GridCardLabelField,
|
||||
SeriesCardCoverMode,
|
||||
TableDensity,
|
||||
} from '@bookorbit/types'
|
||||
@@ -68,6 +74,14 @@ function normalizeSeriesCardCoverMode(value: unknown): SeriesCardCoverMode {
|
||||
: DEFAULT_SERIES_CARD_COVER_MODE
|
||||
}
|
||||
|
||||
function normalizeCardInfoMode(value: unknown): CardInfoMode {
|
||||
return typeof value === 'string' && CARD_INFO_MODES.includes(value as CardInfoMode) ? (value as CardInfoMode) : 'hover-overlay'
|
||||
}
|
||||
|
||||
function normalizeGridCardLabelField(value: unknown): GridCardLabelField {
|
||||
return typeof value === 'string' && GRID_CARD_LABEL_FIELDS.includes(value as GridCardLabelField) ? (value as GridCardLabelField) : 'hidden'
|
||||
}
|
||||
|
||||
function normalizeCoverSizeScope(value: unknown): CoverSizeScope {
|
||||
return typeof value === 'string' && COVER_SIZE_SCOPES.includes(value as CoverSizeScope) ? (value as CoverSizeScope) : 'per-view'
|
||||
}
|
||||
@@ -127,6 +141,9 @@ const bookCoverDisplayMode = ref<BookCoverDisplayMode>(
|
||||
normalizeBookCoverDisplayMode(storage.get('bookCoverDisplayMode', DEFAULT_BOOK_COVER_DISPLAY_MODE)),
|
||||
)
|
||||
const seriesCardCoverMode = ref<SeriesCardCoverMode>(normalizeSeriesCardCoverMode(storage.get('seriesCardCoverMode', DEFAULT_SERIES_CARD_COVER_MODE)))
|
||||
const gridCardPrimaryLabel = ref<GridCardLabelField>(normalizeGridCardLabelField(storage.get('gridCardPrimaryLabel', 'hidden')))
|
||||
const gridCardSecondaryLabel = ref<GridCardLabelField>(normalizeGridCardLabelField(storage.get('gridCardSecondaryLabel', 'hidden')))
|
||||
const cardInfoMode = ref<CardInfoMode>(normalizeCardInfoMode(storage.get('cardInfoMode', 'hover-overlay')))
|
||||
|
||||
watch(portraitCoverSize, (v) => storage.set('portraitCoverSize', v))
|
||||
watch(squareCoverSize, (v) => storage.set('squareCoverSize', v))
|
||||
@@ -145,6 +162,9 @@ watch(bookSpineOverlay, (value) => storage.set('bookSpineOverlay', normalizeBook
|
||||
watch(bookShadowStrength, (value) => storage.set('bookShadowStrength', normalizeBookShadowStrength(value)))
|
||||
watch(bookCoverDisplayMode, (value) => storage.set('bookCoverDisplayMode', normalizeBookCoverDisplayMode(value)))
|
||||
watch(seriesCardCoverMode, (value) => storage.set('seriesCardCoverMode', normalizeSeriesCardCoverMode(value)))
|
||||
watch(gridCardPrimaryLabel, (value) => storage.set('gridCardPrimaryLabel', normalizeGridCardLabelField(value)))
|
||||
watch(gridCardSecondaryLabel, (value) => storage.set('gridCardSecondaryLabel', normalizeGridCardLabelField(value)))
|
||||
watch(cardInfoMode, (value) => storage.set('cardInfoMode', normalizeCardInfoMode(value)))
|
||||
|
||||
export function getDisplayPreferencesSnapshot(): DisplayPreferences {
|
||||
return {
|
||||
@@ -165,6 +185,9 @@ export function getDisplayPreferencesSnapshot(): DisplayPreferences {
|
||||
bookShadowStrength: normalizeBookShadowStrength(bookShadowStrength.value),
|
||||
bookCoverDisplayMode: normalizeBookCoverDisplayMode(bookCoverDisplayMode.value),
|
||||
seriesCardCoverMode: normalizeSeriesCardCoverMode(seriesCardCoverMode.value),
|
||||
gridCardPrimaryLabel: normalizeGridCardLabelField(gridCardPrimaryLabel.value),
|
||||
gridCardSecondaryLabel: normalizeGridCardLabelField(gridCardSecondaryLabel.value),
|
||||
cardInfoMode: normalizeCardInfoMode(cardInfoMode.value),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +220,15 @@ export function sanitizeDisplayPreferences(raw: unknown): Partial<DisplayPrefere
|
||||
if (SERIES_CARD_COVER_MODES.includes(obj.seriesCardCoverMode as SeriesCardCoverMode)) {
|
||||
out.seriesCardCoverMode = obj.seriesCardCoverMode as SeriesCardCoverMode
|
||||
}
|
||||
if (GRID_CARD_LABEL_FIELDS.includes(obj.gridCardPrimaryLabel as GridCardLabelField)) {
|
||||
out.gridCardPrimaryLabel = obj.gridCardPrimaryLabel as GridCardLabelField
|
||||
}
|
||||
if (GRID_CARD_LABEL_FIELDS.includes(obj.gridCardSecondaryLabel as GridCardLabelField)) {
|
||||
out.gridCardSecondaryLabel = obj.gridCardSecondaryLabel as GridCardLabelField
|
||||
}
|
||||
if (CARD_INFO_MODES.includes(obj.cardInfoMode as CardInfoMode)) {
|
||||
out.cardInfoMode = obj.cardInfoMode as CardInfoMode
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -220,6 +252,9 @@ export function applyDisplayPreferences(raw: unknown): void {
|
||||
if (prefs.bookShadowStrength !== undefined) bookShadowStrength.value = prefs.bookShadowStrength
|
||||
if (prefs.bookCoverDisplayMode !== undefined) bookCoverDisplayMode.value = prefs.bookCoverDisplayMode
|
||||
if (prefs.seriesCardCoverMode !== undefined) seriesCardCoverMode.value = prefs.seriesCardCoverMode
|
||||
if (prefs.gridCardPrimaryLabel !== undefined) gridCardPrimaryLabel.value = prefs.gridCardPrimaryLabel
|
||||
if (prefs.gridCardSecondaryLabel !== undefined) gridCardSecondaryLabel.value = prefs.gridCardSecondaryLabel
|
||||
if (prefs.cardInfoMode !== undefined) cardInfoMode.value = prefs.cardInfoMode
|
||||
}
|
||||
|
||||
export function useDisplaySettings() {
|
||||
@@ -241,5 +276,8 @@ export function useDisplaySettings() {
|
||||
bookShadowStrength,
|
||||
bookCoverDisplayMode,
|
||||
seriesCardCoverMode,
|
||||
gridCardPrimaryLabel,
|
||||
gridCardSecondaryLabel,
|
||||
cardInfoMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,9 @@ export function initDisplaySettingsSync(): void {
|
||||
settings.bookShadowStrength.value,
|
||||
settings.bookCoverDisplayMode.value,
|
||||
settings.seriesCardCoverMode.value,
|
||||
settings.gridCardPrimaryLabel.value,
|
||||
settings.gridCardSecondaryLabel.value,
|
||||
settings.cardInfoMode.value,
|
||||
] as const,
|
||||
() => {
|
||||
if (isApplyingServerPrefs || !isSyncEnabled()) return
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import BookCoverCard from './BookCoverCard.vue'
|
||||
@@ -8,6 +8,7 @@ import BookCoverCard from './BookCoverCard.vue'
|
||||
|
||||
const mockRouterPush = vi.fn<() => void>()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ fullPath: '/' }),
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
}))
|
||||
|
||||
@@ -37,14 +38,34 @@ vi.mock('@/features/auth/composables/usePermissions', () => ({
|
||||
usePermissions: () => ({ hasPermission: mockHasPermission }),
|
||||
}))
|
||||
|
||||
const mockFetchAuthors = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
(params: { q?: string; page: number; size: number; sort: string; order: string }) => Promise<{
|
||||
items: Array<{ id: number; name: string; sortName: string | null; bookCount: number; lastAddedAt: string | null }>
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}>
|
||||
>(),
|
||||
)
|
||||
vi.mock('@/features/author/api/author', () => ({
|
||||
fetchAuthors: mockFetchAuthors,
|
||||
}))
|
||||
|
||||
const mockCardOverlays = ref<string[]>([])
|
||||
const mockBookCoverDisplayMode = ref('blurred-fit')
|
||||
const mockGridCardPrimaryLabel = ref('hidden')
|
||||
const mockGridCardSecondaryLabel = ref('hidden')
|
||||
const mockCardInfoMode = ref('hover-overlay')
|
||||
vi.mock('@/composables/useDisplaySettings', () => ({
|
||||
useDisplaySettings: () => ({
|
||||
cardOverlays: mockCardOverlays,
|
||||
bookSpineOverlay: ref('off'),
|
||||
bookShadowStrength: ref('default'),
|
||||
bookCoverDisplayMode: mockBookCoverDisplayMode,
|
||||
gridCardPrimaryLabel: mockGridCardPrimaryLabel,
|
||||
gridCardSecondaryLabel: mockGridCardSecondaryLabel,
|
||||
cardInfoMode: mockCardInfoMode,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -137,7 +158,9 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
|
||||
}
|
||||
}
|
||||
|
||||
function mountCard(props: Partial<{ book: BookCard; selectionMode: boolean; selected: boolean; onSelect: (e: MouseEvent) => void }> = {}) {
|
||||
function mountCard(
|
||||
props: Partial<{ book: BookCard; selectionMode: boolean; selected: boolean; showLabel: boolean; onSelect: (e: MouseEvent) => void }> = {},
|
||||
) {
|
||||
return mount(BookCoverCard, {
|
||||
props: { book: makeBook(), selectionMode: false, selected: false, ...props },
|
||||
global: {
|
||||
@@ -188,6 +211,15 @@ describe('BookCoverCard', () => {
|
||||
mockRefreshing.value = false
|
||||
mockCardOverlays.value = []
|
||||
mockBookCoverDisplayMode.value = 'blurred-fit'
|
||||
mockGridCardPrimaryLabel.value = 'hidden'
|
||||
mockGridCardSecondaryLabel.value = 'hidden'
|
||||
mockCardInfoMode.value = 'hover-overlay'
|
||||
mockFetchAuthors.mockResolvedValue({
|
||||
items: [{ id: 7, name: 'Frank Herbert', sortName: null, bookCount: 1, lastAddedAt: '2024-01-01T00:00:00Z' }],
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 5,
|
||||
})
|
||||
setTouchMode(false)
|
||||
})
|
||||
|
||||
@@ -314,6 +346,46 @@ describe('BookCoverCard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('below-cover label click behavior', () => {
|
||||
it('opens book details when the primary label text is clicked', async () => {
|
||||
mockCardInfoMode.value = 'below-cover'
|
||||
mockGridCardPrimaryLabel.value = 'book-title'
|
||||
const book = makeBook({ id: 42, files: [makeFile({ id: 10, format: 'epub', role: 'primary' })] })
|
||||
const wrapper = mountCard({ book, showLabel: true })
|
||||
|
||||
await wrapper.get('[data-testid="grid-card-label-primary"]').trigger('click')
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 42 } })
|
||||
expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' }))
|
||||
})
|
||||
|
||||
it('opens author details when the author label text is clicked', async () => {
|
||||
mockCardInfoMode.value = 'below-cover'
|
||||
mockGridCardSecondaryLabel.value = 'author'
|
||||
const book = makeBook({ id: 43, files: [makeFile({ id: 11, format: 'epub', role: 'primary' })] })
|
||||
const wrapper = mountCard({ book, showLabel: true })
|
||||
|
||||
await wrapper.get('[data-testid="grid-card-label-secondary"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockFetchAuthors).toHaveBeenCalledWith({ q: 'Frank Herbert', page: 0, size: 5, sort: 'name', order: 'asc' })
|
||||
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'author-detail', params: { id: 7 }, query: { from: '/' } })
|
||||
expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' }))
|
||||
})
|
||||
|
||||
it('opens series details when the series label text is clicked', async () => {
|
||||
mockCardInfoMode.value = 'below-cover'
|
||||
mockGridCardPrimaryLabel.value = 'series-title-position'
|
||||
const book = makeBook({ id: 44, seriesName: 'Dune Chronicles', seriesIndex: 1, files: [makeFile({ id: 12, format: 'epub', role: 'primary' })] })
|
||||
const wrapper = mountCard({ book, showLabel: true })
|
||||
|
||||
await wrapper.get('[data-testid="grid-card-label-primary"]').trigger('click')
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'series-detail', params: { seriesName: 'Dune Chronicles' }, query: { from: '/' } })
|
||||
expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' }))
|
||||
})
|
||||
})
|
||||
|
||||
// ── cover rendering ───────────────────────────────────────────────────────
|
||||
|
||||
describe('cover rendering', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { BookCard, BookFileRef } from '@bookorbit/types'
|
||||
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types'
|
||||
import { getFormatColor } from '../lib/format-colors'
|
||||
import { computed, inject, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
@@ -41,20 +41,23 @@ import { useRefreshMetadata } from '../composables/useRefreshMetadata'
|
||||
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 { useDisplaySettings, type GridCardLabelField } from '@/composables/useDisplaySettings'
|
||||
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 BookCoverArtwork from './BookCoverArtwork.vue'
|
||||
import BookCoverSurface from './BookCoverSurface.vue'
|
||||
import { fetchAuthors } from '@/features/author/api/author'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
book: BookCard
|
||||
selectionMode?: boolean
|
||||
selected?: boolean
|
||||
showLabel?: boolean
|
||||
}>()
|
||||
|
||||
type BookActionType = 'quick-view' | 'add-to-collection' | 'delete'
|
||||
@@ -110,7 +113,7 @@ async function handleRefreshMetadata() {
|
||||
if (updated) emit('update:book', mergeBookCardWithDetail(props.book, updated))
|
||||
}
|
||||
const { hasPermission } = usePermissions()
|
||||
const { cardOverlays, bookCoverDisplayMode } = useDisplaySettings()
|
||||
const { cardOverlays, bookCoverDisplayMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode } = useDisplaySettings()
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
const showSendDialog = ref(false)
|
||||
|
||||
@@ -184,6 +187,64 @@ function openFile(file: BookFileRef) {
|
||||
})
|
||||
}
|
||||
|
||||
function openBookDetails() {
|
||||
void router.push({ name: 'book-detail', params: { bookId: props.book.id } })
|
||||
}
|
||||
|
||||
function openSeriesDetails() {
|
||||
const seriesName = props.book.seriesName?.trim()
|
||||
if (!seriesName) return
|
||||
void router.push({ name: 'series-detail', params: { seriesName }, query: { from: route.fullPath } })
|
||||
}
|
||||
|
||||
async function openAuthorDetails() {
|
||||
const authorName = authorQuery.value?.trim()
|
||||
if (!authorName) return
|
||||
|
||||
try {
|
||||
const page = await fetchAuthors({ q: authorName, page: 0, size: 5, sort: 'name', order: 'asc' })
|
||||
const author = page.items.find((item) => item.name.trim().toLocaleLowerCase() === authorName.toLocaleLowerCase())
|
||||
if (author) {
|
||||
void router.push({ name: 'author-detail', params: { id: author.id }, query: { from: route.fullPath } })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the filtered author list below.
|
||||
}
|
||||
|
||||
void router.push({ name: 'authors', query: { q: authorName } })
|
||||
}
|
||||
|
||||
function openLabelTarget(field: GridCardLabelField) {
|
||||
if (field === 'book-title') {
|
||||
openBookDetails()
|
||||
return
|
||||
}
|
||||
if (field === 'author') {
|
||||
void openAuthorDetails()
|
||||
return
|
||||
}
|
||||
if (field === 'series-title' || field === 'series-title-position') {
|
||||
openSeriesDetails()
|
||||
}
|
||||
}
|
||||
|
||||
function handleLabelClick(field: GridCardLabelField, event: MouseEvent) {
|
||||
if (props.selectionMode) {
|
||||
emit('select', event)
|
||||
return
|
||||
}
|
||||
openLabelTarget(field)
|
||||
}
|
||||
|
||||
function handlePrimaryLabelClick(event: MouseEvent) {
|
||||
handleLabelClick(gridCardPrimaryLabel.value, event)
|
||||
}
|
||||
|
||||
function handleSecondaryLabelClick(event: MouseEvent) {
|
||||
handleLabelClick(gridCardSecondaryLabel.value, event)
|
||||
}
|
||||
|
||||
function handleCardClick(event: MouseEvent) {
|
||||
const target = event.target
|
||||
if (target instanceof Element && target.closest('button, [data-card-click-blocker]')) return
|
||||
@@ -224,10 +285,16 @@ function openAuthorBrowse() {
|
||||
void router.push({ name: 'authors', query: { q: authorQuery.value } })
|
||||
}
|
||||
|
||||
const showHoverText = computed(() => coverLoaded.value || !props.book.hasCover || coverFailed.value)
|
||||
const showAuthorOnHover = computed(() => showHoverText.value && !!authorLine.value)
|
||||
const showHoverTitle = computed(
|
||||
() => (cardInfoMode.value === 'hover-overlay' || !props.showLabel) && (coverLoaded.value || !props.book.hasCover || coverFailed.value),
|
||||
)
|
||||
const showAuthorOnHover = computed(() => showHoverTitle.value && !!authorLine.value)
|
||||
const hoverTitleClampClass = computed(() => (coverAspectRatio.value === '1/1' ? 'line-clamp-1' : 'line-clamp-2'))
|
||||
|
||||
const overlayBackground = computed(() => (cardInfoMode.value === 'hover-overlay' || !props.showLabel ? 'bg-black/70' : 'bg-black/20'))
|
||||
const overlayFadeClass = computed(() => (cardInfoMode.value === 'hover-overlay' || !props.showLabel ? 'group-hover:opacity-0' : ''))
|
||||
const showBelowCoverLabelArea = computed(() => props.showLabel && cardInfoMode.value === 'below-cover')
|
||||
|
||||
const { downloadFile, exportBooks } = useBookDownload()
|
||||
|
||||
function handleDownloadFile(file: BookFileRef) {
|
||||
@@ -260,292 +327,450 @@ async function handleSetStatus(status: ReadStatus) {
|
||||
localReadStatus.value = prev
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBookLabel(field: GridCardLabelField): string | null {
|
||||
if (field === 'hidden') return null
|
||||
if (field === 'book-title') return props.book.title?.trim() || null
|
||||
if (field === 'series-title') return props.book.seriesName?.trim() || null
|
||||
if (field === 'series-title-position') {
|
||||
const name = props.book.seriesName?.trim()
|
||||
if (!name) return null
|
||||
return props.book.seriesIndex != null ? `${name} #${props.book.seriesIndex}` : name
|
||||
}
|
||||
if (field === 'author') return props.book.authors.length > 0 ? props.book.authors.join(', ') : null
|
||||
return null
|
||||
}
|
||||
|
||||
const primaryLabelText = computed(() => resolveBookLabel(gridCardPrimaryLabel.value))
|
||||
const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabel.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="group flex flex-col @container touch-manipulation"
|
||||
class="flex flex-col @container touch-manipulation"
|
||||
:class="[selectionMode || (primaryFile && !isMissing) ? 'cursor-pointer' : 'cursor-default', selectionMode ? 'select-none' : '']"
|
||||
@click="handleCardClick"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<!-- Cover -->
|
||||
<BookCoverSurface
|
||||
class="book-cover-surface--spine-fitted relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform,ring] duration-150 will-change-transform"
|
||||
:class="isMissing || selectionMode ? '' : 'group-hover:scale-[1.02]'"
|
||||
:interactive="!isMissing && !selectionMode"
|
||||
:disable-spine="isAudiobook"
|
||||
: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" />
|
||||
<div class="group">
|
||||
<BookCoverSurface
|
||||
class="book-cover-surface--spine-fitted relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform,ring] duration-150 will-change-transform"
|
||||
:class="isMissing || selectionMode ? '' : 'group-hover:scale-[1.02]'"
|
||||
:interactive="!isMissing && !selectionMode"
|
||||
:disable-spine="isAudiobook"
|
||||
: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" />
|
||||
|
||||
<BookCoverArtwork
|
||||
:src="coverSrc"
|
||||
: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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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}%` }"
|
||||
<BookCoverArtwork
|
||||
:src="coverSrc"
|
||||
: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"
|
||||
/>
|
||||
|
||||
<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 data-testid="cover-overlay-frame" class="absolute z-10 overflow-hidden rounded-[inherit]" :style="coverOverlayFrameStyle">
|
||||
<!-- Top-left overlay: read status -->
|
||||
<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'"
|
||||
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 transition-opacity duration-150"
|
||||
:class="[overlayFadeClass, overlayFadeClass ? '' : showMobileOverlay ? 'opacity-0 pointer-events-none' : '']"
|
||||
>
|
||||
<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>
|
||||
<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 transition-opacity duration-150"
|
||||
:class="showMobileOverlay ? 'opacity-0 pointer-events-none' : 'group-hover:opacity-0'"
|
||||
>
|
||||
<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 flex items-center bg-black/60 rounded-full px-1.5 py-0.5 transition-opacity duration-150 cursor-default"
|
||||
:class="showMobileOverlay ? 'opacity-0 pointer-events-none' : 'group-hover:opacity-0 group-hover:pointer-events-none'"
|
||||
>
|
||||
<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 transition-opacity duration-150"
|
||||
:class="overlayFadeClass"
|
||||
>
|
||||
<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 pointer-events-none transition-opacity duration-150"
|
||||
:class="overlayFadeClass"
|
||||
>
|
||||
<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,opacity] 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', overlayFadeClass]"
|
||||
: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 transition-opacity duration-150"
|
||||
:class="[
|
||||
overlayBackground,
|
||||
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 (hover-overlay mode only) + kebab (when not in below-cover label row) -->
|
||||
<div class="shrink-0 flex flex-col pr-10">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p v-if="showHoverTitle" 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 (not in below-cover mode, where it lives in the label row) -->
|
||||
<div v-if="!showBelowCoverLabelArea" 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="openBookDetails">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Below-cover label row (below-cover mode only) -->
|
||||
<div v-if="showBelowCoverLabelArea" class="grid-card-label" data-testid="grid-card-label">
|
||||
<div class="grid-card-label__primary-row">
|
||||
<button
|
||||
v-if="primaryLabelText"
|
||||
type="button"
|
||||
class="grid-card-label__button grid-card-label__primary min-w-0 flex-1 truncate"
|
||||
data-testid="grid-card-label-primary"
|
||||
@click.stop="handlePrimaryLabelClick"
|
||||
>
|
||||
{{ primaryLabelText }}
|
||||
</button>
|
||||
<div v-else class="flex-1" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="shrink-0 p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors" data-testid="grid-card-kebab">
|
||||
<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
|
||||
</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>
|
||||
|
||||
<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 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="openBookDetails">
|
||||
<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' } })">
|
||||
<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>
|
||||
</BookCoverSurface>
|
||||
<button
|
||||
v-if="secondaryLabelText"
|
||||
type="button"
|
||||
class="grid-card-label__button grid-card-label__secondary w-full"
|
||||
data-testid="grid-card-label-secondary"
|
||||
@click.stop="handleSecondaryLabelClick"
|
||||
>
|
||||
{{ secondaryLabelText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SendBookDialog
|
||||
@@ -567,4 +792,56 @@ async function handleSetStatus(status: ReadStatus) {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.grid-card-label {
|
||||
padding-top: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grid-card-label__primary-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grid-card-label__button {
|
||||
display: block;
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.grid-card-label__button:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.grid-card-label__button:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.grid-card-label__primary {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.grid-card-label__secondary {
|
||||
font-size: 0.625rem;
|
||||
line-height: 0.875rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,26 +2,38 @@
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
|
||||
import { Library } from 'lucide-vue-next'
|
||||
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
|
||||
import BookCoverSurface from './BookCoverSurface.vue'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
||||
import { useCoverVersions } from '../composables/useCoverVersions'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useDisplaySettings, type GridCardLabelField } from '@/composables/useDisplaySettings'
|
||||
import { fetchAuthors } from '@/features/author/api/author'
|
||||
|
||||
const props = defineProps<{
|
||||
book: BookCard
|
||||
showLabel?: boolean
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
const { coverUrl } = useCoverVersions()
|
||||
const { seriesCardCoverMode } = useDisplaySettings()
|
||||
const { seriesCardCoverMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, cardOverlays } = useDisplaySettings()
|
||||
|
||||
const collapsed = computed(() => props.book.collapsedSeries!)
|
||||
const seriesName = computed(() => props.book.seriesName ?? '')
|
||||
const authorLine = computed(() => props.book.authors.join(', ') || null)
|
||||
const authorQuery = computed(() => props.book.authors[0] ?? null)
|
||||
const hoverTitleClampClass = computed(() => (coverAspectRatio.value === '1/1' ? 'line-clamp-1' : 'line-clamp-2'))
|
||||
const showHoverText = computed(() => cardInfoMode.value === 'hover-overlay')
|
||||
const overlayBackground = computed(() => (cardInfoMode.value === 'hover-overlay' ? 'bg-black/70' : 'bg-black/20'))
|
||||
const showProgressBar = computed(() => cardOverlays.value.includes('progress-bar'))
|
||||
const progressPercent = computed(() => {
|
||||
const { bookCount, readCount } = collapsed.value
|
||||
if (bookCount <= 0) return 0
|
||||
return Math.min(100, Math.max(0, (readCount / bookCount) * 100))
|
||||
})
|
||||
|
||||
const isMosaic = computed(() => seriesCardCoverMode.value === 'mosaic')
|
||||
|
||||
@@ -61,7 +73,47 @@ function handleSingleCoverError() {
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
router.push({ name: 'series-detail', params: { seriesName: seriesName.value }, query: { from: route.fullPath } })
|
||||
openSeriesDetails()
|
||||
}
|
||||
|
||||
function openSeriesDetails() {
|
||||
const name = seriesName.value.trim()
|
||||
if (!name) return
|
||||
void router.push({ name: 'series-detail', params: { seriesName: name }, query: { from: route.fullPath } })
|
||||
}
|
||||
|
||||
async function openAuthorDetails() {
|
||||
const authorName = authorQuery.value?.trim()
|
||||
if (!authorName) return
|
||||
|
||||
try {
|
||||
const page = await fetchAuthors({ q: authorName, page: 0, size: 5, sort: 'name', order: 'asc' })
|
||||
const author = page.items.find((item) => item.name.trim().toLocaleLowerCase() === authorName.toLocaleLowerCase())
|
||||
if (author) {
|
||||
void router.push({ name: 'author-detail', params: { id: author.id }, query: { from: route.fullPath } })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the filtered author list below.
|
||||
}
|
||||
|
||||
void router.push({ name: 'authors', query: { q: authorName } })
|
||||
}
|
||||
|
||||
function handleLabelClick(field: GridCardLabelField) {
|
||||
if (field === 'author') {
|
||||
void openAuthorDetails()
|
||||
return
|
||||
}
|
||||
openSeriesDetails()
|
||||
}
|
||||
|
||||
function handlePrimaryLabelClick() {
|
||||
handleLabelClick(gridCardPrimaryLabel.value)
|
||||
}
|
||||
|
||||
function handleSecondaryLabelClick() {
|
||||
handleLabelClick(gridCardSecondaryLabel.value)
|
||||
}
|
||||
|
||||
function tileClass(index: number): string {
|
||||
@@ -70,72 +122,177 @@ function tileClass(index: number): string {
|
||||
if (tileCount.value === 3 && index === 0) return 'row-span-2'
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveSeriesLabel(field: GridCardLabelField): string | null {
|
||||
if (field === 'hidden') return null
|
||||
if (field === 'author') return authorLine.value?.trim() || null
|
||||
return seriesName.value?.trim() || null
|
||||
}
|
||||
|
||||
const primaryLabelText = computed(() => resolveSeriesLabel(gridCardPrimaryLabel.value))
|
||||
const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLabel.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group flex flex-col @container cursor-pointer" @click="handleClick">
|
||||
<div class="flex flex-col @container cursor-pointer" @click="handleClick">
|
||||
<!-- Cover -->
|
||||
<BookCoverSurface
|
||||
class="relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform] duration-150 will-change-transform group-hover:scale-[1.02]"
|
||||
interactive
|
||||
:disable-spine="isAudiobook"
|
||||
:style="{ aspectRatio: coverAspectRatio }"
|
||||
>
|
||||
<!-- Single cover mode -->
|
||||
<div v-if="!isMosaic && resolvedCoverId != null" class="absolute inset-0" data-testid="series-single-cover">
|
||||
<img
|
||||
v-if="!singleCoverFailed"
|
||||
:src="coverUrl(resolvedCoverId)"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
@error="handleSingleCoverError"
|
||||
/>
|
||||
<BookCoverPlaceholder v-else title="" author-line="" :is-audio="false" :seed="`series-${resolvedCoverId}`" />
|
||||
</div>
|
||||
<div class="group">
|
||||
<BookCoverSurface
|
||||
class="relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform] duration-150 will-change-transform group-hover:scale-[1.02]"
|
||||
interactive
|
||||
:disable-spine="isAudiobook"
|
||||
:style="{ aspectRatio: coverAspectRatio }"
|
||||
>
|
||||
<!-- Single cover mode -->
|
||||
<div v-if="!isMosaic && resolvedCoverId != null" class="absolute inset-0" data-testid="series-single-cover">
|
||||
<img
|
||||
v-if="!singleCoverFailed"
|
||||
:src="coverUrl(resolvedCoverId)"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
@error="handleSingleCoverError"
|
||||
/>
|
||||
<BookCoverPlaceholder v-else title="" author-line="" :is-audio="false" :seed="`series-${resolvedCoverId}`" />
|
||||
</div>
|
||||
|
||||
<!-- Adaptive cover mosaic (default mode) -->
|
||||
<div v-else class="absolute inset-0 grid grid-cols-2 grid-rows-2">
|
||||
<template v-for="(bookId, i) in coverIds" :key="bookId">
|
||||
<div class="relative overflow-hidden" :class="tileClass(i)" data-testid="series-cover-tile">
|
||||
<img
|
||||
v-if="!failedCovers.has(bookId)"
|
||||
:src="coverUrl(bookId)"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
@error="() => handleCoverError(bookId)"
|
||||
/>
|
||||
<BookCoverPlaceholder v-else title="" author-line="" :is-audio="false" :seed="`series-${bookId}`" />
|
||||
<!-- Adaptive cover mosaic (default mode) -->
|
||||
<div v-else class="absolute inset-0 grid grid-cols-2 grid-rows-2">
|
||||
<template v-for="(bookId, i) in coverIds" :key="bookId">
|
||||
<div class="relative overflow-hidden" :class="tileClass(i)" data-testid="series-cover-tile">
|
||||
<img
|
||||
v-if="!failedCovers.has(bookId)"
|
||||
:src="coverUrl(bookId)"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
@error="() => handleCoverError(bookId)"
|
||||
/>
|
||||
<BookCoverPlaceholder v-else title="" author-line="" :is-audio="false" :seed="`series-${bookId}`" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="coverIds.length === 0" class="relative overflow-hidden" :class="tileClass(0)" data-testid="series-cover-fallback">
|
||||
<BookCoverPlaceholder title="" author-line="" :is-audio="false" seed="series-empty" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="coverIds.length === 0" class="relative overflow-hidden" :class="tileClass(0)" data-testid="series-cover-fallback">
|
||||
<BookCoverPlaceholder title="" author-line="" :is-audio="false" seed="series-empty" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Count badge -->
|
||||
<div
|
||||
class="absolute right-1.5 top-1.5 z-10 rounded-sm bg-black/70 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-white group-hover:opacity-0 transition-opacity duration-150"
|
||||
data-testid="series-count-badge"
|
||||
>
|
||||
{{ collapsed.bookCount }}
|
||||
</div>
|
||||
|
||||
<!-- Hover overlay -->
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col p-2 bg-black/70 opacity-0 group-hover:opacity-100 group-active:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<div class="shrink-0 flex flex-col">
|
||||
<p class="text-xs font-semibold text-white leading-tight" :class="hoverTitleClampClass">
|
||||
{{ seriesName }}
|
||||
</p>
|
||||
<p v-if="authorLine" class="text-[10px] text-white/70 truncate mt-0.5">
|
||||
{{ authorLine }}
|
||||
</p>
|
||||
<!-- Count badge -->
|
||||
<div
|
||||
class="absolute right-1.5 top-1.5 z-10 rounded-sm bg-black/70 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-white group-hover:opacity-0 transition-opacity duration-150"
|
||||
data-testid="series-count-badge"
|
||||
>
|
||||
{{ collapsed.bookCount }}
|
||||
</div>
|
||||
</div>
|
||||
</BookCoverSurface>
|
||||
|
||||
<!-- Series type badge -->
|
||||
<div
|
||||
class="absolute bottom-1.5 left-1.5 z-10 rounded-sm bg-black/70 p-1 text-white group-hover:opacity-0 transition-opacity duration-150"
|
||||
data-testid="series-type-badge"
|
||||
>
|
||||
<Library :size="10" />
|
||||
</div>
|
||||
|
||||
<!-- Hover overlay -->
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col p-2 opacity-0 group-hover:opacity-100 group-active:opacity-100 transition-opacity duration-150 pointer-events-none group-hover:pointer-events-auto group-active:pointer-events-auto"
|
||||
:class="overlayBackground"
|
||||
>
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<button
|
||||
class="size-[30cqi] flex items-center justify-center rounded-full bg-primary text-white shadow-2xl transition-all duration-300 scale-75 group-hover:scale-100"
|
||||
data-testid="series-hover-action"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<Library class="size-[16cqi]" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showHoverText" class="shrink-0 flex flex-col">
|
||||
<p class="text-xs font-semibold text-white leading-tight" :class="hoverTitleClampClass">
|
||||
{{ seriesName }}
|
||||
</p>
|
||||
<p v-if="authorLine" class="text-[10px] text-white/70 truncate mt-0.5">
|
||||
{{ authorLine }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Read progress bar -->
|
||||
<div v-if="showProgressBar" class="absolute bottom-0 left-0 right-0 h-[3px] z-20 bg-white/20" data-testid="series-progress-bar">
|
||||
<div class="h-full bg-primary" :style="{ width: `${progressPercent}%` }" data-testid="series-progress-fill" />
|
||||
</div>
|
||||
</BookCoverSurface>
|
||||
</div>
|
||||
|
||||
<div v-if="props.showLabel && cardInfoMode === 'below-cover' && (primaryLabelText || secondaryLabelText)" class="grid-card-label">
|
||||
<button
|
||||
v-if="primaryLabelText"
|
||||
type="button"
|
||||
class="grid-card-label__primary"
|
||||
data-testid="grid-card-label-primary"
|
||||
@click.stop="handlePrimaryLabelClick"
|
||||
>
|
||||
{{ primaryLabelText }}
|
||||
</button>
|
||||
<button
|
||||
v-if="secondaryLabelText"
|
||||
type="button"
|
||||
class="grid-card-label__secondary"
|
||||
data-testid="grid-card-label-secondary"
|
||||
@click.stop="handleSecondaryLabelClick"
|
||||
>
|
||||
{{ secondaryLabelText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid-card-label {
|
||||
padding-top: 4px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.grid-card-label__primary {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--foreground);
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid-card-label__primary:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.grid-card-label__secondary {
|
||||
font-size: 0.625rem;
|
||||
line-height: 0.875rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 2px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid-card-label__secondary:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import VirtualBookGrid from './VirtualBookGrid.vue'
|
||||
@@ -14,21 +15,32 @@ vi.mock('vue-virtual-scroller', () => ({
|
||||
vi.mock('./BookCoverCard.vue', () => ({
|
||||
default: {
|
||||
name: 'BookCoverCard',
|
||||
props: ['book', 'selectionMode', 'selected'],
|
||||
props: ['book', 'selectionMode', 'selected', 'showLabel'],
|
||||
emits: ['action', 'select', 'update:book'],
|
||||
template: '<button data-testid="book-card" @click="$emit(\'action\', \'quick-view\')">{{ book.id }}</button>',
|
||||
template:
|
||||
'<button data-testid="book-card" @click="$emit(\'action\', \'quick-view\')">{{ book.id }}<span v-if="showLabel" data-testid="book-card-label-slot" /></button>',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./CollapsedSeriesCard.vue', () => ({
|
||||
default: {
|
||||
name: 'CollapsedSeriesCard',
|
||||
props: ['book'],
|
||||
template: '<div data-testid="collapsed-series-card">{{ book.id }}</div>',
|
||||
props: ['book', 'showLabel'],
|
||||
template: '<div data-testid="collapsed-series-card">{{ book.id }}<span v-if="showLabel" data-testid="series-card-label-slot" /></div>',
|
||||
},
|
||||
}))
|
||||
|
||||
function makeBook(id: number): BookCard {
|
||||
const displaySettingsState = {
|
||||
gridCardPrimaryLabel: ref('hidden'),
|
||||
gridCardSecondaryLabel: ref('hidden'),
|
||||
cardInfoMode: ref('hover-overlay'),
|
||||
}
|
||||
|
||||
vi.mock('@/composables/useDisplaySettings', () => ({
|
||||
useDisplaySettings: () => displaySettingsState,
|
||||
}))
|
||||
|
||||
function makeBook(id: number, overrides: Partial<BookCard> = {}): BookCard {
|
||||
return {
|
||||
id,
|
||||
status: 'present',
|
||||
@@ -55,6 +67,7 @@ function makeBook(id: number): BookCard {
|
||||
pageCount: null,
|
||||
isbn13: null,
|
||||
narrators: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,4 +115,61 @@ describe('VirtualBookGrid', () => {
|
||||
|
||||
expect(wrapper.emitted('action')).toEqual([[books[0], 'quick-view']])
|
||||
})
|
||||
|
||||
describe('show-label prop forwarding', () => {
|
||||
afterEach(() => {
|
||||
displaySettingsState.cardInfoMode.value = 'hover-overlay'
|
||||
})
|
||||
|
||||
it('does not pass showLabel when cardInfoMode is hover-overlay', () => {
|
||||
displaySettingsState.cardInfoMode.value = 'hover-overlay'
|
||||
|
||||
const wrapper = mount(VirtualBookGrid, {
|
||||
props: { books: [makeBook(1)], coverSize: 120, gridGap: 12, virtualized: false },
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="book-card-label-slot"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('passes showLabel=true when cardInfoMode is below-cover', () => {
|
||||
displaySettingsState.cardInfoMode.value = 'below-cover'
|
||||
|
||||
const wrapper = mount(VirtualBookGrid, {
|
||||
props: { books: [makeBook(1)], coverSize: 120, gridGap: 12, virtualized: false },
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="book-card-label-slot"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not pass showLabel when cardInfoMode is off', () => {
|
||||
displaySettingsState.cardInfoMode.value = 'off'
|
||||
|
||||
const wrapper = mount(VirtualBookGrid, {
|
||||
props: { books: [makeBook(1)], coverSize: 120, gridGap: 12, virtualized: false },
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="book-card-label-slot"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('passes showLabel to CollapsedSeriesCard when cardInfoMode is below-cover', () => {
|
||||
displaySettingsState.cardInfoMode.value = 'below-cover'
|
||||
|
||||
const seriesBook = makeBook(1, {
|
||||
collapsedSeries: {
|
||||
bookCount: 3,
|
||||
readCount: 0,
|
||||
coverBookIds: [],
|
||||
seriesLatestAddedAt: null,
|
||||
firstVolumeBookId: null,
|
||||
latestVolumeBookId: null,
|
||||
firstUnreadBookId: null,
|
||||
},
|
||||
})
|
||||
const wrapper = mount(VirtualBookGrid, {
|
||||
props: { books: [seriesBook], coverSize: 120, gridGap: 12, virtualized: false },
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="series-card-label-slot"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { BookCard } from '@bookorbit/types'
|
||||
import BookCoverCard from './BookCoverCard.vue'
|
||||
import CollapsedSeriesCard from './CollapsedSeriesCard.vue'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
|
||||
type BookActionType = 'quick-view' | 'add-to-collection' | 'delete'
|
||||
|
||||
@@ -75,12 +76,22 @@ const aspectMultiplier = computed(() => (coverAspectRatio.value === '1/1' ? 1 :
|
||||
|
||||
const cardWidth = computed(() => Math.max(1, itemSecondarySize.value - gapPx.value))
|
||||
const cardHeight = computed(() => Math.max(1, Math.round(cardWidth.value * aspectMultiplier.value)))
|
||||
const itemSize = computed(() => cardHeight.value + gapPx.value)
|
||||
|
||||
const { gridCardSecondaryLabel, cardInfoMode } = useDisplaySettings()
|
||||
const labelAreaHeight = computed(() => {
|
||||
if (cardInfoMode.value !== 'below-cover') return 0
|
||||
const hasSecondary = gridCardSecondaryLabel.value !== 'hidden'
|
||||
return hasSecondary ? 40 : 24
|
||||
})
|
||||
const showLabel = computed(() => cardInfoMode.value === 'below-cover')
|
||||
|
||||
const itemSize = computed(() => cardHeight.value + labelAreaHeight.value + gapPx.value)
|
||||
const buffer = computed(() => Math.max(itemSize.value * 2, 240))
|
||||
|
||||
const scrollerStyle = computed(() => ({
|
||||
'--book-grid-gap': `${gapPx.value}px`,
|
||||
'--book-grid-height': `${cardHeight.value}px`,
|
||||
'--book-grid-label-height': `${labelAreaHeight.value}px`,
|
||||
}))
|
||||
|
||||
const staticGridStyle = computed(() => ({
|
||||
@@ -93,10 +104,11 @@ const staticGridStyle = computed(() => ({
|
||||
<div ref="containerRef" class="w-full">
|
||||
<div v-if="!virtualized" class="grid w-full max-w-full items-start" :style="staticGridStyle" data-testid="book-grid-static">
|
||||
<div v-for="book in books" :key="book.id" class="min-w-0" :class="{ 'book-grid-cell--new': props.newBookIds.has(book.id) }">
|
||||
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" />
|
||||
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" :show-label="showLabel" />
|
||||
<BookCoverCard
|
||||
v-else
|
||||
:book="book"
|
||||
:show-label="showLabel"
|
||||
:selection-mode="selectionMode"
|
||||
:selected="isSelected?.(book.id) ?? false"
|
||||
@action="emit('action', book, $event)"
|
||||
@@ -120,10 +132,11 @@ const staticGridStyle = computed(() => ({
|
||||
>
|
||||
<template #default="{ item: book }">
|
||||
<div class="book-grid-cell" :class="{ 'book-grid-cell--new': props.newBookIds.has(book.id) }">
|
||||
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" />
|
||||
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" :show-label="showLabel" />
|
||||
<BookCoverCard
|
||||
v-else
|
||||
:book="book"
|
||||
:show-label="showLabel"
|
||||
:selection-mode="selectionMode"
|
||||
:selected="isSelected?.(book.id) ?? false"
|
||||
@action="emit('action', book, $event)"
|
||||
@@ -155,7 +168,7 @@ const staticGridStyle = computed(() => ({
|
||||
}
|
||||
|
||||
.book-grid-cell {
|
||||
height: var(--book-grid-height);
|
||||
height: calc(var(--book-grid-height) + var(--book-grid-label-height, 0px));
|
||||
box-sizing: border-box;
|
||||
padding-left: 0;
|
||||
padding-right: var(--book-grid-gap);
|
||||
|
||||
@@ -8,8 +8,11 @@ import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
return { ...actual, useRouter: () => ({ push: vi.fn<(...args: unknown[]) => unknown>() }) }
|
||||
return { ...actual, useRoute: () => ({ fullPath: '/' }), useRouter: () => ({ push: vi.fn<(...args: unknown[]) => unknown>() }) }
|
||||
})
|
||||
vi.mock('@/features/author/api/author', () => ({
|
||||
fetchAuthors: vi.fn<(...args: unknown[]) => unknown>(),
|
||||
}))
|
||||
vi.mock('@/features/book/composables/useCoverVersions', () => ({
|
||||
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn<(...args: unknown[]) => void>() }),
|
||||
}))
|
||||
@@ -58,12 +61,15 @@ function mountCard(book: BookCard, coverAspectRatio: '2/3' | '1/1' = '2/3') {
|
||||
})
|
||||
}
|
||||
|
||||
const { cardOverlays, bookSpineOverlay, bookShadowStrength } = useDisplaySettings()
|
||||
const { cardOverlays, bookSpineOverlay, bookShadowStrength, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode } = useDisplaySettings()
|
||||
|
||||
afterEach(() => {
|
||||
cardOverlays.value = ['progress-bar', 'format', 'rating', 'read-status']
|
||||
bookSpineOverlay.value = 'off'
|
||||
bookShadowStrength.value = 'default'
|
||||
gridCardPrimaryLabel.value = 'hidden'
|
||||
gridCardSecondaryLabel.value = 'hidden'
|
||||
cardInfoMode.value = 'hover-overlay'
|
||||
})
|
||||
|
||||
const missingBook: BookCard = {
|
||||
@@ -153,7 +159,7 @@ describe('BookCoverCard — missing state', () => {
|
||||
|
||||
it('uses cursor-default on the root when book is missing', () => {
|
||||
const wrapper = mountCard(missingBook)
|
||||
const root = wrapper.find('div.group')
|
||||
const root = wrapper.find('div')
|
||||
expect(root.classes()).toContain('cursor-default')
|
||||
})
|
||||
})
|
||||
@@ -358,3 +364,105 @@ describe('BookCoverCard — series position overlay', () => {
|
||||
expect(wrapper.find('.text-amber-400').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BookCoverCard — grid card labels', () => {
|
||||
const bookWithMeta: BookCard = {
|
||||
...presentBook,
|
||||
title: 'Dune',
|
||||
authors: ['Frank Herbert'],
|
||||
seriesName: 'Dune Chronicles',
|
||||
seriesIndex: 1,
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
gridCardPrimaryLabel.value = 'hidden'
|
||||
gridCardSecondaryLabel.value = 'hidden'
|
||||
cardInfoMode.value = 'hover-overlay'
|
||||
})
|
||||
|
||||
function mountWithLabel(book: BookCard = bookWithMeta) {
|
||||
cardInfoMode.value = 'below-cover'
|
||||
return mount(BookCoverCard, {
|
||||
props: { book, showLabel: true },
|
||||
global: {
|
||||
...globalStubs,
|
||||
provide: { [COVER_ASPECT_RATIO_KEY as symbol]: ref('2/3') },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('does not render label area when cardInfoMode is hover-overlay', () => {
|
||||
gridCardPrimaryLabel.value = 'book-title'
|
||||
cardInfoMode.value = 'hover-overlay'
|
||||
const wrapper = mountCard(bookWithMeta)
|
||||
expect(wrapper.find('[data-testid="grid-card-label"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render label text elements when both fields are hidden in below-cover mode', () => {
|
||||
gridCardPrimaryLabel.value = 'hidden'
|
||||
gridCardSecondaryLabel.value = 'hidden'
|
||||
const wrapper = mountWithLabel()
|
||||
expect(wrapper.find('[data-testid="grid-card-label-primary"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="grid-card-label-secondary"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders primary label with book title', () => {
|
||||
gridCardPrimaryLabel.value = 'book-title'
|
||||
const wrapper = mountWithLabel()
|
||||
const label = wrapper.find('[data-testid="grid-card-label-primary"]')
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(label.text()).toBe('Dune')
|
||||
})
|
||||
|
||||
it('renders primary label with series title', () => {
|
||||
gridCardPrimaryLabel.value = 'series-title'
|
||||
const wrapper = mountWithLabel()
|
||||
const label = wrapper.find('[data-testid="grid-card-label-primary"]')
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(label.text()).toBe('Dune Chronicles')
|
||||
})
|
||||
|
||||
it('renders primary label with series title and position', () => {
|
||||
gridCardPrimaryLabel.value = 'series-title-position'
|
||||
const wrapper = mountWithLabel()
|
||||
const label = wrapper.find('[data-testid="grid-card-label-primary"]')
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(label.text()).toBe('Dune Chronicles #1')
|
||||
})
|
||||
|
||||
it('renders primary label with author', () => {
|
||||
gridCardPrimaryLabel.value = 'author'
|
||||
const wrapper = mountWithLabel()
|
||||
const label = wrapper.find('[data-testid="grid-card-label-primary"]')
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(label.text()).toBe('Frank Herbert')
|
||||
})
|
||||
|
||||
it('hides primary label line when field resolves to null (missing series)', () => {
|
||||
gridCardPrimaryLabel.value = 'series-title'
|
||||
cardInfoMode.value = 'below-cover'
|
||||
const wrapper = mount(BookCoverCard, {
|
||||
props: { book: { ...presentBook, title: 'Standalone', seriesName: null }, showLabel: true },
|
||||
global: { ...globalStubs, provide: { [COVER_ASPECT_RATIO_KEY as symbol]: ref('2/3') } },
|
||||
})
|
||||
expect(wrapper.find('[data-testid="grid-card-label-primary"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders secondary label alongside primary', () => {
|
||||
gridCardPrimaryLabel.value = 'book-title'
|
||||
gridCardSecondaryLabel.value = 'author'
|
||||
const wrapper = mountWithLabel()
|
||||
expect(wrapper.find('[data-testid="grid-card-label-primary"]').text()).toBe('Dune')
|
||||
expect(wrapper.find('[data-testid="grid-card-label-secondary"]').text()).toBe('Frank Herbert')
|
||||
})
|
||||
|
||||
it('formats series-title-position with fractional index', () => {
|
||||
gridCardPrimaryLabel.value = 'series-title-position'
|
||||
cardInfoMode.value = 'below-cover'
|
||||
const wrapper = mount(BookCoverCard, {
|
||||
props: { book: { ...bookWithMeta, seriesIndex: 1.5 }, showLabel: true },
|
||||
global: { ...globalStubs, provide: { [COVER_ASPECT_RATIO_KEY as symbol]: ref('2/3') } },
|
||||
})
|
||||
expect(wrapper.find('[data-testid="grid-card-label-primary"]').text()).toBe('Dune Chronicles #1.5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,9 @@ import CollapsedSeriesCard from '../CollapsedSeriesCard.vue'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
|
||||
const mockRouterPush = vi.fn<(...args: unknown[]) => unknown>()
|
||||
const { mockFetchAuthors } = vi.hoisted(() => ({
|
||||
mockFetchAuthors: vi.fn<(...args: unknown[]) => unknown>(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
@@ -19,6 +22,10 @@ vi.mock('../BookCoverPlaceholder.vue', () => ({
|
||||
default: { template: '<div class="book-cover-placeholder" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/features/author/api/author', () => ({
|
||||
fetchAuthors: mockFetchAuthors,
|
||||
}))
|
||||
|
||||
function makeBook(overrides?: Partial<BookCard>): BookCard {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -56,17 +63,22 @@ function makeBook(overrides?: Partial<BookCard>): BookCard {
|
||||
}
|
||||
}
|
||||
|
||||
const { bookSpineOverlay, seriesCardCoverMode } = useDisplaySettings()
|
||||
const { bookSpineOverlay, seriesCardCoverMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, cardOverlays } = useDisplaySettings()
|
||||
|
||||
describe('CollapsedSeriesCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
seriesCardCoverMode.value = 'mosaic'
|
||||
mockFetchAuthors.mockResolvedValue({ items: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
bookSpineOverlay.value = 'off'
|
||||
seriesCardCoverMode.value = 'mosaic'
|
||||
gridCardPrimaryLabel.value = 'hidden'
|
||||
gridCardSecondaryLabel.value = 'hidden'
|
||||
cardInfoMode.value = 'hover-overlay'
|
||||
cardOverlays.value = ['progress-bar', 'format', 'rating', 'read-status', 'series-position']
|
||||
})
|
||||
|
||||
it('renders four cover tiles when all books are represented by covers', () => {
|
||||
@@ -435,4 +447,236 @@ describe('CollapsedSeriesCard', () => {
|
||||
expect(wrapper.findAll('[data-testid="series-cover-tile"]')).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('series type badge', () => {
|
||||
it('renders series type badge on cover', () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, { props: { book: makeBook() } })
|
||||
|
||||
expect(wrapper.find('[data-testid="series-type-badge"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hover action button', () => {
|
||||
it('renders Library action button in hover overlay', () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, { props: { book: makeBook() } })
|
||||
|
||||
expect(wrapper.find('[data-testid="series-hover-action"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hover overlay has pointer-events-none to prevent invisible clicks', () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, { props: { book: makeBook() } })
|
||||
|
||||
const overlay = wrapper.find('[data-testid="series-hover-action"]').element.closest('.pointer-events-none')
|
||||
expect(overlay).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clicking Library button navigates to series-detail', async () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, { props: { book: makeBook() } })
|
||||
|
||||
await wrapper.find('[data-testid="series-hover-action"]').trigger('click')
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith({
|
||||
name: 'series-detail',
|
||||
params: { seriesName: 'The Arc' },
|
||||
query: { from: '/test-path' },
|
||||
})
|
||||
})
|
||||
|
||||
it('clicking Library button stops propagation so card handleClick does not also fire', async () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, { props: { book: makeBook() } })
|
||||
|
||||
await wrapper.find('[data-testid="series-hover-action"]').trigger('click')
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not navigate when seriesName is empty', async () => {
|
||||
const wrapper = mount(CollapsedSeriesCard, { props: { book: makeBook({ seriesName: '' }) } })
|
||||
|
||||
await wrapper.find('[data-testid="series-hover-action"]').trigger('click')
|
||||
|
||||
expect(mockRouterPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('read progress bar', () => {
|
||||
it('renders progress bar when progress-bar overlay is enabled', () => {
|
||||
cardOverlays.value = ['progress-bar']
|
||||
const wrapper = mount(CollapsedSeriesCard, { props: { book: makeBook() } })
|
||||
|
||||
expect(wrapper.find('[data-testid="series-progress-bar"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render progress bar when progress-bar overlay is disabled', () => {
|
||||
cardOverlays.value = ['format', 'rating']
|
||||
const wrapper = mount(CollapsedSeriesCard, { props: { book: makeBook() } })
|
||||
|
||||
expect(wrapper.find('[data-testid="series-progress-bar"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('fill width is 0% when readCount is 0', () => {
|
||||
cardOverlays.value = ['progress-bar']
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: { book: makeBook({ collapsedSeries: { bookCount: 5, readCount: 0, coverBookIds: [], seriesLatestAddedAt: null } }) },
|
||||
})
|
||||
|
||||
const fill = wrapper.find('[data-testid="series-progress-fill"]')
|
||||
expect(fill.attributes('style')).toBe('width: 0%;')
|
||||
})
|
||||
|
||||
it('fill width is 100% when all books are read', () => {
|
||||
cardOverlays.value = ['progress-bar']
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: { book: makeBook({ collapsedSeries: { bookCount: 5, readCount: 5, coverBookIds: [], seriesLatestAddedAt: null } }) },
|
||||
})
|
||||
|
||||
const fill = wrapper.find('[data-testid="series-progress-fill"]')
|
||||
expect(fill.attributes('style')).toBe('width: 100%;')
|
||||
})
|
||||
|
||||
it('fill width is 40% when readCount is 2 out of 5', () => {
|
||||
cardOverlays.value = ['progress-bar']
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: { book: makeBook({ collapsedSeries: { bookCount: 5, readCount: 2, coverBookIds: [], seriesLatestAddedAt: null } }) },
|
||||
})
|
||||
|
||||
const fill = wrapper.find('[data-testid="series-progress-fill"]')
|
||||
expect(fill.attributes('style')).toBe('width: 40%;')
|
||||
})
|
||||
|
||||
it('handles bookCount of 0 without crash and shows 0% width', () => {
|
||||
cardOverlays.value = ['progress-bar']
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: { book: makeBook({ collapsedSeries: { bookCount: 0, readCount: 0, coverBookIds: [], seriesLatestAddedAt: null } }) },
|
||||
})
|
||||
|
||||
const fill = wrapper.find('[data-testid="series-progress-fill"]')
|
||||
expect(fill.attributes('style')).toBe('width: 0%;')
|
||||
})
|
||||
|
||||
it('clamps to 100% even if readCount exceeds bookCount', () => {
|
||||
cardOverlays.value = ['progress-bar']
|
||||
const wrapper = mount(CollapsedSeriesCard, {
|
||||
props: { book: makeBook({ collapsedSeries: { bookCount: 3, readCount: 10, coverBookIds: [], seriesLatestAddedAt: null } }) },
|
||||
})
|
||||
|
||||
const fill = wrapper.find('[data-testid="series-progress-fill"]')
|
||||
expect(fill.attributes('style')).toBe('width: 100%;')
|
||||
})
|
||||
})
|
||||
|
||||
describe('below-cover label buttons', () => {
|
||||
function mountWithBelowCoverLabel(overrides?: Partial<BookCard>) {
|
||||
cardInfoMode.value = 'below-cover'
|
||||
return mount(CollapsedSeriesCard, { props: { book: makeBook(overrides), showLabel: true } })
|
||||
}
|
||||
|
||||
it('renders label buttons not paragraphs in below-cover mode', () => {
|
||||
gridCardPrimaryLabel.value = 'series-title'
|
||||
const wrapper = mountWithBelowCoverLabel()
|
||||
|
||||
const primary = wrapper.find('[data-testid="grid-card-label-primary"]')
|
||||
expect(primary.element.tagName).toBe('BUTTON')
|
||||
})
|
||||
|
||||
it('does not render label area when showLabel is false', () => {
|
||||
gridCardPrimaryLabel.value = 'series-title'
|
||||
cardInfoMode.value = 'below-cover'
|
||||
const wrapper = mount(CollapsedSeriesCard, { props: { book: makeBook(), showLabel: false } })
|
||||
|
||||
expect(wrapper.find('[data-testid="grid-card-label-primary"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('clicking series name label navigates to series-detail', async () => {
|
||||
gridCardPrimaryLabel.value = 'series-title'
|
||||
const wrapper = mountWithBelowCoverLabel()
|
||||
|
||||
await wrapper.find('[data-testid="grid-card-label-primary"]').trigger('click')
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith({
|
||||
name: 'series-detail',
|
||||
params: { seriesName: 'The Arc' },
|
||||
query: { from: '/test-path' },
|
||||
})
|
||||
})
|
||||
|
||||
it('clicking series name label stops propagation so card does not also navigate', async () => {
|
||||
gridCardPrimaryLabel.value = 'series-title'
|
||||
const wrapper = mountWithBelowCoverLabel()
|
||||
|
||||
await wrapper.find('[data-testid="grid-card-label-primary"]').trigger('click')
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clicking author label calls fetchAuthors to resolve author', async () => {
|
||||
gridCardSecondaryLabel.value = 'author'
|
||||
const wrapper = mountWithBelowCoverLabel()
|
||||
|
||||
await wrapper.find('[data-testid="grid-card-label-secondary"]').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(mockFetchAuthors).toHaveBeenCalledWith({
|
||||
q: 'Author A',
|
||||
page: 0,
|
||||
size: 5,
|
||||
sort: 'name',
|
||||
order: 'asc',
|
||||
})
|
||||
})
|
||||
|
||||
it('clicking author label navigates to author-detail when author is found by API', async () => {
|
||||
gridCardSecondaryLabel.value = 'author'
|
||||
mockFetchAuthors.mockResolvedValue({ items: [{ id: 42, name: 'Author A' }] })
|
||||
const wrapper = mountWithBelowCoverLabel()
|
||||
|
||||
await wrapper.find('[data-testid="grid-card-label-secondary"]').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith({
|
||||
name: 'author-detail',
|
||||
params: { id: 42 },
|
||||
query: { from: '/test-path' },
|
||||
})
|
||||
})
|
||||
|
||||
it('clicking author label navigates to authors list when author is not found by API', async () => {
|
||||
gridCardSecondaryLabel.value = 'author'
|
||||
mockFetchAuthors.mockResolvedValue({ items: [] })
|
||||
const wrapper = mountWithBelowCoverLabel()
|
||||
|
||||
await wrapper.find('[data-testid="grid-card-label-secondary"]').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith({
|
||||
name: 'authors',
|
||||
query: { q: 'Author A' },
|
||||
})
|
||||
})
|
||||
|
||||
it('clicking author label falls back to authors list when fetchAuthors throws', async () => {
|
||||
gridCardSecondaryLabel.value = 'author'
|
||||
mockFetchAuthors.mockRejectedValue(new Error('network error'))
|
||||
const wrapper = mountWithBelowCoverLabel()
|
||||
|
||||
await wrapper.find('[data-testid="grid-card-label-secondary"]').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith({
|
||||
name: 'authors',
|
||||
query: { q: 'Author A' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not navigate when clicking author label with no authors', async () => {
|
||||
gridCardSecondaryLabel.value = 'author'
|
||||
const wrapper = mountWithBelowCoverLabel({ authors: [] })
|
||||
|
||||
const secondary = wrapper.find('[data-testid="grid-card-label-secondary"]')
|
||||
expect(secondary.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,6 +59,8 @@ vi.mock('@/composables/useDisplaySettings', () => ({
|
||||
useDisplaySettings: () => ({
|
||||
portraitCoverSize: ref(160),
|
||||
gridGap: ref(16),
|
||||
gridCardPrimaryLabel: ref('hidden'),
|
||||
gridCardSecondaryLabel: ref('hidden'),
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
import { computed } from 'vue'
|
||||
import { Circle, Square } from 'lucide-vue-next'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import { useDisplaySettings, type AuthorCoverShape, type CoverSizeScope, type SeriesCardCoverMode } from '@/composables/useDisplaySettings'
|
||||
import {
|
||||
useDisplaySettings,
|
||||
type AuthorCoverShape,
|
||||
type CardInfoMode,
|
||||
type CoverSizeScope,
|
||||
type GridCardLabelField,
|
||||
type SeriesCardCoverMode,
|
||||
} from '@/composables/useDisplaySettings'
|
||||
|
||||
const {
|
||||
portraitCoverSize,
|
||||
@@ -14,9 +21,13 @@ const {
|
||||
authorCoverShape,
|
||||
tableZebraStriping,
|
||||
seriesCardCoverMode,
|
||||
gridCardPrimaryLabel,
|
||||
gridCardSecondaryLabel,
|
||||
cardInfoMode,
|
||||
} = useDisplaySettings()
|
||||
|
||||
const syncModeEnabled = computed(() => coverSizeScope.value === 'synced')
|
||||
const showLabelFields = computed(() => cardInfoMode.value === 'below-cover')
|
||||
|
||||
function setCoverSizeScope(mode: CoverSizeScope) {
|
||||
coverSizeScope.value = mode
|
||||
@@ -30,6 +41,34 @@ function setSeriesCardCoverMode(mode: SeriesCardCoverMode) {
|
||||
seriesCardCoverMode.value = mode
|
||||
}
|
||||
|
||||
function setHoverOverlayMode() {
|
||||
cardInfoMode.value = 'hover-overlay' as CardInfoMode
|
||||
}
|
||||
|
||||
function setBelowCoverMode() {
|
||||
cardInfoMode.value = 'below-cover' as CardInfoMode
|
||||
}
|
||||
|
||||
function setOffMode() {
|
||||
cardInfoMode.value = 'off' as CardInfoMode
|
||||
}
|
||||
|
||||
const LABEL_FIELD_OPTIONS: { value: GridCardLabelField; label: string }[] = [
|
||||
{ value: 'hidden', label: 'Hidden' },
|
||||
{ value: 'book-title', label: 'Book title' },
|
||||
{ value: 'series-title', label: 'Series title' },
|
||||
{ value: 'series-title-position', label: 'Series title + position' },
|
||||
{ value: 'author', label: 'Author' },
|
||||
]
|
||||
|
||||
function handlePrimaryLabelChange(event: Event) {
|
||||
gridCardPrimaryLabel.value = (event.target as HTMLSelectElement).value as GridCardLabelField
|
||||
}
|
||||
|
||||
function handleSecondaryLabelChange(event: Event) {
|
||||
gridCardSecondaryLabel.value = (event.target as HTMLSelectElement).value as GridCardLabelField
|
||||
}
|
||||
|
||||
function handlePortraitCoverSizeInput(event: Event) {
|
||||
portraitCoverSize.value = Number((event.target as HTMLInputElement).value)
|
||||
}
|
||||
@@ -183,6 +222,68 @@ function handleAuthorCoverSizeInput(event: Event) {
|
||||
/>
|
||||
</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">Card info mode</p>
|
||||
<p class="settings-hint">Where to show book title and author on grid cards</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="cardInfoMode === 'hover-overlay' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click="setHoverOverlayMode"
|
||||
>
|
||||
On hover
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class="cardInfoMode === 'below-cover' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click="setBelowCoverMode"
|
||||
>
|
||||
Below cover
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class="cardInfoMode === 'off' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click="setOffMode"
|
||||
>
|
||||
Off
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="settings-expand">
|
||||
<div v-if="showLabelFields" class="divide-y divide-border">
|
||||
<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">Primary card label</p>
|
||||
<p class="settings-hint">Text shown below each book cover (first line)</p>
|
||||
</div>
|
||||
<select
|
||||
:value="gridCardPrimaryLabel"
|
||||
class="w-full md:w-48 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-xs focus:outline-none focus:ring-2 focus:ring-primary/50 cursor-pointer"
|
||||
@change="handlePrimaryLabelChange"
|
||||
>
|
||||
<option v-for="opt in LABEL_FIELD_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
</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">Secondary card label</p>
|
||||
<p class="settings-hint">Additional text below the primary label (second line)</p>
|
||||
</div>
|
||||
<select
|
||||
:value="gridCardSecondaryLabel"
|
||||
class="w-full md:w-48 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-xs focus:outline-none focus:ring-2 focus:ring-primary/50 cursor-pointer"
|
||||
@change="handleSecondaryLabelChange"
|
||||
>
|
||||
<option v-for="opt in LABEL_FIELD_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -300,3 +401,20 @@ function handleAuthorCoverSizeInput(event: Event) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-expand-enter-active,
|
||||
.settings-expand-leave-active {
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
max-height 0.25s ease;
|
||||
overflow: hidden;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.settings-expand-enter-from,
|
||||
.settings-expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,6 +34,9 @@ const displaySnapshot = {
|
||||
bookSpineOverlay: 'off',
|
||||
bookShadowStrength: 'default',
|
||||
bookCoverDisplayMode: 'blurred-fit',
|
||||
gridCardPrimaryLabel: 'hidden',
|
||||
gridCardSecondaryLabel: 'hidden',
|
||||
cardInfoMode: 'hover-overlay',
|
||||
}
|
||||
|
||||
const displayRefs = {
|
||||
@@ -50,6 +53,9 @@ const displayRefs = {
|
||||
bookSpineOverlay: ref('off'),
|
||||
bookShadowStrength: ref('default'),
|
||||
bookCoverDisplayMode: ref('blurred-fit'),
|
||||
gridCardPrimaryLabel: ref('hidden'),
|
||||
gridCardSecondaryLabel: ref('hidden'),
|
||||
cardInfoMode: ref('hover-overlay'),
|
||||
}
|
||||
|
||||
const themeStore = {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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 GRID_CARD_LABEL_FIELDS = ["hidden", "book-title", "series-title", "series-title-position", "author"] as const;
|
||||
export type GridCardLabelField = (typeof GRID_CARD_LABEL_FIELDS)[number];
|
||||
|
||||
export const COVER_SIZE_SCOPES = ["per-view", "synced"] as const;
|
||||
export type CoverSizeScope = (typeof COVER_SIZE_SCOPES)[number];
|
||||
|
||||
@@ -25,6 +28,9 @@ export type BookCoverDisplayMode = (typeof BOOK_COVER_DISPLAY_MODES)[number];
|
||||
export const SERIES_CARD_COVER_MODES = ["mosaic", "first-volume", "latest-volume", "first-unread"] as const;
|
||||
export type SeriesCardCoverMode = (typeof SERIES_CARD_COVER_MODES)[number];
|
||||
|
||||
export const CARD_INFO_MODES = ["hover-overlay", "below-cover", "off"] as const;
|
||||
export type CardInfoMode = (typeof CARD_INFO_MODES)[number];
|
||||
|
||||
export interface DisplayPreferences {
|
||||
portraitCoverSize: number;
|
||||
squareCoverSize: number;
|
||||
@@ -43,4 +49,7 @@ export interface DisplayPreferences {
|
||||
bookShadowStrength: BookShadowStrength;
|
||||
bookCoverDisplayMode: BookCoverDisplayMode;
|
||||
seriesCardCoverMode: SeriesCardCoverMode;
|
||||
gridCardPrimaryLabel: GridCardLabelField;
|
||||
gridCardSecondaryLabel: GridCardLabelField;
|
||||
cardInfoMode: CardInfoMode;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ const validDisplayPreferences: DisplayPreferences = {
|
||||
bookSpineOverlay: 'subtle',
|
||||
bookShadowStrength: 'strong',
|
||||
bookCoverDisplayMode: 'natural-bottom',
|
||||
seriesCardCoverMode: 'mosaic',
|
||||
gridCardPrimaryLabel: 'hidden',
|
||||
gridCardSecondaryLabel: 'hidden',
|
||||
};
|
||||
|
||||
describe('UserPreferencesController', () => {
|
||||
|
||||
@@ -31,6 +31,9 @@ const validDisplayPreferences: DisplayPreferences = {
|
||||
bookShadowStrength: 'strong',
|
||||
bookCoverDisplayMode: 'natural-bottom',
|
||||
seriesCardCoverMode: 'mosaic',
|
||||
gridCardPrimaryLabel: 'hidden',
|
||||
gridCardSecondaryLabel: 'hidden',
|
||||
cardInfoMode: 'hover-overlay',
|
||||
};
|
||||
|
||||
const repo = {
|
||||
@@ -220,4 +223,49 @@ describe('UserPreferencesService', () => {
|
||||
await expect(service.upsertDisplayPreferences(11, validDisplayPreferences as unknown as Record<string, unknown>)).rejects.toBe(err);
|
||||
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', validDisplayPreferences);
|
||||
});
|
||||
|
||||
it('upsertDisplayPreferences accepts all valid gridCardPrimaryLabel values', async () => {
|
||||
for (const value of ['hidden', 'book-title', 'series-title', 'series-title-position', 'author']) {
|
||||
await expect(
|
||||
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, gridCardPrimaryLabel: value } as unknown as Record<string, unknown>),
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('upsertDisplayPreferences accepts all valid gridCardSecondaryLabel values', async () => {
|
||||
for (const value of ['hidden', 'book-title', 'series-title', 'series-title-position', 'author']) {
|
||||
await expect(
|
||||
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, gridCardSecondaryLabel: value } as unknown as Record<string, unknown>),
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('upsertDisplayPreferences rejects invalid gridCardPrimaryLabel value', async () => {
|
||||
await expect(
|
||||
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, gridCardPrimaryLabel: 'unknown-field' } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('upsertDisplayPreferences rejects invalid gridCardSecondaryLabel value', async () => {
|
||||
await expect(
|
||||
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, gridCardSecondaryLabel: 'bad-value' } as unknown as Record<string, unknown>),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('upsertDisplayPreferences defaults gridCardPrimaryLabel to hidden when omitted', async () => {
|
||||
const { gridCardPrimaryLabel, ...withoutPrimary } = validDisplayPreferences;
|
||||
void gridCardPrimaryLabel;
|
||||
await expect(service.upsertDisplayPreferences(11, withoutPrimary as unknown as Record<string, unknown>)).resolves.toBeUndefined();
|
||||
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ gridCardPrimaryLabel: 'hidden' }));
|
||||
});
|
||||
|
||||
it('upsertDisplayPreferences defaults gridCardSecondaryLabel to hidden when omitted', async () => {
|
||||
const { gridCardSecondaryLabel, ...withoutSecondary } = validDisplayPreferences;
|
||||
void gridCardSecondaryLabel;
|
||||
await expect(service.upsertDisplayPreferences(11, withoutSecondary as unknown as Record<string, unknown>)).resolves.toBeUndefined();
|
||||
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ gridCardSecondaryLabel: 'hidden' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
BOOK_SHADOW_STRENGTHS,
|
||||
BOOK_SPINE_OVERLAYS,
|
||||
BOOK_VIEW_MODES,
|
||||
CARD_INFO_MODES,
|
||||
CARD_OVERLAY_KEYS,
|
||||
COVER_SIZE_SCOPES,
|
||||
GRID_CARD_LABEL_FIELDS,
|
||||
RADIUS_IDS,
|
||||
SERIES_CARD_COVER_MODES,
|
||||
TABLE_DENSITIES,
|
||||
@@ -50,6 +52,9 @@ const DISPLAY_PREFERENCES_SCHEMA = z
|
||||
bookShadowStrength: z.enum(BOOK_SHADOW_STRENGTHS),
|
||||
bookCoverDisplayMode: z.enum(BOOK_COVER_DISPLAY_MODES),
|
||||
seriesCardCoverMode: z.enum(SERIES_CARD_COVER_MODES).default('mosaic'),
|
||||
gridCardPrimaryLabel: z.enum(GRID_CARD_LABEL_FIELDS).default('hidden'),
|
||||
gridCardSecondaryLabel: z.enum(GRID_CARD_LABEL_FIELDS).default('hidden'),
|
||||
cardInfoMode: z.enum(CARD_INFO_MODES).default('hover-overlay'),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((data, ctx) => {
|
||||
|
||||
Reference in New Issue
Block a user