mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(client): support square audiobook covers and inline series cells
Refactors the virtual book table to render collapsed series as standard inline cells (BookTableCollapsedSeriesCell) rather than full-width rows, improving table alignment and layout consistency. Updates the dashboard scroller and cover artwork components to support 1:1 aspect ratios, ensuring audiobook covers are displayed correctly without distortion.
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
--book-cover-spine-width: 6%;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background-color: white;
|
||||
background-color: color-mix(in oklch, var(--foreground) 12%, var(--background));
|
||||
background-size: cover;
|
||||
box-shadow: var(--book-cover-shadow);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,16 @@ function handleImageError() {
|
||||
|
||||
<template>
|
||||
<template v-if="canRenderImage">
|
||||
<span
|
||||
:class="[
|
||||
'absolute inset-0 z-0 transition-opacity duration-200 ease-out',
|
||||
loaded ? 'opacity-0 pointer-events-none' : 'opacity-100',
|
||||
effectiveMode === 'natural-bottom' ? 'book-cover-artwork-placeholder-natural overflow-hidden rounded-[inherit]' : '',
|
||||
]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<BookCoverPlaceholder :title="title" :author-line="authorLine" :is-audio="isAudio" :seed="seed" />
|
||||
</span>
|
||||
<img
|
||||
v-if="showBlurredBackdrop"
|
||||
:src="src ?? ''"
|
||||
@@ -143,7 +153,7 @@ function handleImageError() {
|
||||
/>
|
||||
<span v-if="showSpineLayer" class="book-cover-spine-layer absolute inset-0 z-[3]" :style="spineStyle" />
|
||||
</span>
|
||||
<span v-if="!loaded" class="absolute inset-0 z-[2] animate-pulse bg-white/10" />
|
||||
<span v-if="!loaded" class="absolute inset-0 z-[2] animate-pulse bg-foreground/5" />
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
|
||||
@@ -56,19 +56,42 @@ const resolvedCoverId = computed<number | null>(() => {
|
||||
})
|
||||
|
||||
const failedCovers = ref(new Set<number>())
|
||||
const loadedCovers = ref(new Set<number>())
|
||||
const singleCoverFailed = ref(false)
|
||||
const singleCoverLoaded = ref(false)
|
||||
|
||||
watch(resolvedCoverId, () => {
|
||||
singleCoverFailed.value = false
|
||||
singleCoverLoaded.value = false
|
||||
})
|
||||
watch(
|
||||
coverIds,
|
||||
(ids) => {
|
||||
const activeIds = new Set(ids)
|
||||
failedCovers.value = new Set([...failedCovers.value].filter((id) => activeIds.has(id)))
|
||||
loadedCovers.value = new Set([...loadedCovers.value].filter((id) => activeIds.has(id)))
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
|
||||
|
||||
function handleCoverLoad(bookId: number) {
|
||||
loadedCovers.value = new Set([...loadedCovers.value, bookId])
|
||||
}
|
||||
|
||||
function handleCoverError(bookId: number) {
|
||||
loadedCovers.value = new Set([...loadedCovers.value].filter((id) => id !== bookId))
|
||||
failedCovers.value = new Set([...failedCovers.value, bookId])
|
||||
}
|
||||
|
||||
function handleSingleCoverLoad() {
|
||||
singleCoverLoaded.value = true
|
||||
singleCoverFailed.value = false
|
||||
}
|
||||
|
||||
function handleSingleCoverError() {
|
||||
singleCoverLoaded.value = false
|
||||
singleCoverFailed.value = true
|
||||
}
|
||||
|
||||
@@ -145,30 +168,48 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
|
||||
>
|
||||
<!-- Single cover mode -->
|
||||
<div v-if="!isMosaic && resolvedCoverId != null" class="absolute inset-0" data-testid="series-single-cover">
|
||||
<div
|
||||
class="absolute inset-0 transition-opacity duration-200 ease-out"
|
||||
:class="singleCoverLoaded && !singleCoverFailed ? 'opacity-0 pointer-events-none' : 'opacity-100'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<BookCoverPlaceholder title="" author-line="" :is-audio="false" :seed="`series-${resolvedCoverId}`" />
|
||||
</div>
|
||||
<img
|
||||
v-if="!singleCoverFailed"
|
||||
:src="coverUrl(resolvedCoverId)"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-out"
|
||||
:class="singleCoverLoaded ? 'opacity-100' : 'opacity-0'"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
@load="handleSingleCoverLoad"
|
||||
@error="handleSingleCoverError"
|
||||
/>
|
||||
<BookCoverPlaceholder v-else title="" author-line="" :is-audio="false" :seed="`series-${resolvedCoverId}`" />
|
||||
<span v-if="!singleCoverLoaded && !singleCoverFailed" class="absolute inset-0 z-[1] animate-pulse bg-foreground/5" />
|
||||
</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">
|
||||
<div
|
||||
class="absolute inset-0 transition-opacity duration-200 ease-out"
|
||||
:class="loadedCovers.has(bookId) && !failedCovers.has(bookId) ? 'opacity-0 pointer-events-none' : 'opacity-100'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<BookCoverPlaceholder title="" author-line="" :is-audio="false" :seed="`series-${bookId}`" />
|
||||
</div>
|
||||
<img
|
||||
v-if="!failedCovers.has(bookId)"
|
||||
:src="coverUrl(bookId)"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-out"
|
||||
:class="loadedCovers.has(bookId) ? 'opacity-100' : 'opacity-0'"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
@load="() => handleCoverLoad(bookId)"
|
||||
@error="() => handleCoverError(bookId)"
|
||||
/>
|
||||
<BookCoverPlaceholder v-else title="" author-line="" :is-audio="false" :seed="`series-${bookId}`" />
|
||||
<span v-if="!loadedCovers.has(bookId) && !failedCovers.has(bookId)" class="absolute inset-0 z-[1] animate-pulse bg-foreground/5" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="coverIds.length === 0" class="relative overflow-hidden" :class="tileClass(0)" data-testid="series-cover-fallback">
|
||||
|
||||
@@ -31,11 +31,12 @@ import { useRefreshMetadata } from '@/features/book/composables/useRefreshMetada
|
||||
import { useRefreshingBooks } from '@/features/book/composables/useRefreshingBooks'
|
||||
import { useBookRefreshFeedback } from '@/features/book/composables/useBookRefreshFeedback'
|
||||
import type { InFlightOp } from '@/features/book/composables/useBookBulkActions'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { detectChangedColumns, mergeBookCardWithDetail } from '@/features/book/lib/book-card-mapper'
|
||||
import { buildLockStateBookUpdate, mergeBookPatchWithLatest, sameLockFields } from '@/features/book/lib/table-row-state-sync'
|
||||
import BookCoverDialog from './table/BookCoverDialog.vue'
|
||||
import BookTableCellDispatcher from './table/BookTableCellDispatcher.vue'
|
||||
import BookTableCollapsedSeriesRow from './table/BookTableCollapsedSeriesRow.vue'
|
||||
import BookTableCollapsedSeriesCell from './table/BookTableCollapsedSeriesCell.vue'
|
||||
import BookTableContextMenu from './table/BookTableContextMenu.vue'
|
||||
import BookTableHeader from './table/BookTableHeader.vue'
|
||||
import BookTableHeaderContextMenu from './table/BookTableHeaderContextMenu.vue'
|
||||
@@ -79,6 +80,7 @@ const emit = defineEmits<{
|
||||
const { md } = useBreakpoints(breakpointsTailwind)
|
||||
const isReadOnly = computed(() => !md.value)
|
||||
const { tableDensity, tableZebraStriping } = useDisplaySettings()
|
||||
const router = useRouter()
|
||||
|
||||
const { layout, visibleColumns, allColumns, toggleColumn, setColumnOrder, setColumnWidth, setLayout, pinColumn, unpinColumn, resetLayout } =
|
||||
useTableColumns(props.viewType)
|
||||
@@ -508,6 +510,10 @@ function handleNavigate(book: BookCard, colId: ColumnId, direction: 'next' | 'pr
|
||||
function handleRowClick(book: BookCard, e: MouseEvent) {
|
||||
if (props.selectionMode) {
|
||||
emit('select', book.id, e)
|
||||
return
|
||||
}
|
||||
if (book.collapsedSeries) {
|
||||
router.push({ name: 'series-detail', params: { seriesName: book.seriesName ?? '' } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,92 +711,74 @@ defineExpose({
|
||||
@click="handleRowClick(books[vItem.index]!, $event)"
|
||||
@contextmenu.prevent="(event) => handleRowContextMenu(event, books[vItem.index]!)"
|
||||
>
|
||||
<!-- Collapsed series row: span all columns with dedicated component -->
|
||||
<template v-if="books[vItem.index]!.collapsedSeries">
|
||||
<td
|
||||
v-if="selectionMode"
|
||||
role="gridcell"
|
||||
class="overflow-hidden px-2 bg-background"
|
||||
:class="rowPaddingClass"
|
||||
:style="{ width: '36px', minWidth: '36px', position: 'sticky', left: '0px', zIndex: 20 }"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-primary h-3.5 w-3.5 cursor-pointer"
|
||||
:checked="isSelected?.(books[vItem.index]!.id) ?? false"
|
||||
@click.stop="handleCheckboxClick(books[vItem.index]!.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<BookTableCollapsedSeriesRow :book="books[vItem.index]!" :colspan="displayColumns.length" :selection-mode="selectionMode" />
|
||||
</template>
|
||||
<!-- Checkbox cell -->
|
||||
<td
|
||||
v-if="selectionMode"
|
||||
role="gridcell"
|
||||
class="overflow-hidden px-2 bg-background"
|
||||
:class="rowPaddingClass"
|
||||
:style="{ width: '36px', minWidth: '36px', position: 'sticky', left: '0px', zIndex: 20 }"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-primary h-3.5 w-3.5 cursor-pointer"
|
||||
:checked="isSelected?.(books[vItem.index]!.id) ?? false"
|
||||
@click.stop="handleCheckboxClick(books[vItem.index]!.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<template v-else>
|
||||
<!-- Checkbox cell -->
|
||||
<td
|
||||
v-if="selectionMode"
|
||||
role="gridcell"
|
||||
class="overflow-hidden px-2 bg-background"
|
||||
:class="rowPaddingClass"
|
||||
:style="{ width: '36px', minWidth: '36px', position: 'sticky', left: '0px', zIndex: 20 }"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-primary h-3.5 w-3.5 cursor-pointer"
|
||||
:checked="isSelected?.(books[vItem.index]!.id) ?? false"
|
||||
@click.stop="handleCheckboxClick(books[vItem.index]!.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td
|
||||
v-for="(col, colIdx) in displayColumns"
|
||||
:key="col.id"
|
||||
:data-col-id="col.id"
|
||||
:data-row-index="vItem.index"
|
||||
role="gridcell"
|
||||
class="relative overflow-hidden px-2 align-middle"
|
||||
:class="[
|
||||
rowPaddingClass,
|
||||
col.pinned === 'right' ? 'border-l border-border/60' : '',
|
||||
col.pinned === null ? (isMandatoryFieldEmpty(books[vItem.index]!, col.id) ? 'bg-amber-500/5' : '') : '',
|
||||
isCellChanged(books[vItem.index]!.id, col.id) ? 'book-cell--changed' : '',
|
||||
isFocusedCell(vItem.index, colIdx) && !editor.isActive(books[vItem.index]!.id, col.id)
|
||||
? 'outline outline-2 outline-primary/50 -outline-offset-2 rounded-sm'
|
||||
: '',
|
||||
]"
|
||||
:style="{
|
||||
width: `${col.defaultWidth}px`,
|
||||
minWidth: `${col.minWidth}px`,
|
||||
...(col.pinned === 'left' || col.id === 'lockRow'
|
||||
? {
|
||||
position: 'sticky',
|
||||
left: `${(selectionMode ? 36 : 0) + (pinnedLeftOffsets.get(col.id) ?? 0)}px`,
|
||||
zIndex: col.id === 'lockRow' ? 20 : 2,
|
||||
background: getPinnedCellBackground(
|
||||
books[vItem.index]!,
|
||||
col.id,
|
||||
selectionMode && (isSelected?.(books[vItem.index]!.id) ?? false),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(col.pinned === 'right'
|
||||
? {
|
||||
position: 'sticky',
|
||||
right: '0',
|
||||
zIndex: 2,
|
||||
background: getPinnedCellBackground(
|
||||
books[vItem.index]!,
|
||||
col.id,
|
||||
selectionMode && (isSelected?.(books[vItem.index]!.id) ?? false),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}"
|
||||
@click="focusTableCell(vItem.index, colIdx, $event, books[vItem.index]!.id, col.id)"
|
||||
>
|
||||
<td
|
||||
v-for="(col, colIdx) in displayColumns"
|
||||
:key="col.id"
|
||||
:data-col-id="col.id"
|
||||
:data-row-index="vItem.index"
|
||||
role="gridcell"
|
||||
class="relative overflow-hidden px-2 align-middle"
|
||||
:class="[
|
||||
rowPaddingClass,
|
||||
col.pinned === 'right' ? 'border-l border-border/60' : '',
|
||||
col.pinned === null ? (isMandatoryFieldEmpty(books[vItem.index]!, col.id) ? 'bg-amber-500/5' : '') : '',
|
||||
isCellChanged(books[vItem.index]!.id, col.id) ? 'book-cell--changed' : '',
|
||||
isFocusedCell(vItem.index, colIdx) && !editor.isActive(books[vItem.index]!.id, col.id)
|
||||
? 'outline outline-2 outline-primary/50 -outline-offset-2 rounded-sm'
|
||||
: '',
|
||||
]"
|
||||
:style="{
|
||||
width: `${col.defaultWidth}px`,
|
||||
minWidth: `${col.minWidth}px`,
|
||||
...(col.pinned === 'left' || col.id === 'lockRow'
|
||||
? {
|
||||
position: 'sticky',
|
||||
left: `${(selectionMode ? 36 : 0) + (pinnedLeftOffsets.get(col.id) ?? 0)}px`,
|
||||
zIndex: col.id === 'lockRow' ? 20 : 2,
|
||||
background: getPinnedCellBackground(
|
||||
books[vItem.index]!,
|
||||
col.id,
|
||||
selectionMode && (isSelected?.(books[vItem.index]!.id) ?? false),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(col.pinned === 'right'
|
||||
? {
|
||||
position: 'sticky',
|
||||
right: '0',
|
||||
zIndex: 2,
|
||||
background: getPinnedCellBackground(
|
||||
books[vItem.index]!,
|
||||
col.id,
|
||||
selectionMode && (isSelected?.(books[vItem.index]!.id) ?? false),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}"
|
||||
@click="focusTableCell(vItem.index, colIdx, $event, books[vItem.index]!.id, col.id)"
|
||||
>
|
||||
<template v-if="books[vItem.index]!.collapsedSeries">
|
||||
<BookTableCollapsedSeriesCell :book="books[vItem.index]!" :col-id="col.id" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<BookTableCellDispatcher
|
||||
:book="books[vItem.index]!"
|
||||
:col-id="col.id"
|
||||
@@ -855,8 +843,8 @@ defineExpose({
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="virtualItems.length > 0" role="row">
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('BookCoverArtwork', () => {
|
||||
|
||||
await wrapper.setProps({ resetKey: 'retry-1' })
|
||||
expect(wrapper.find('img[alt="Dune cover"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render the fitted spine layer when spine is disabled', async () => {
|
||||
|
||||
@@ -291,11 +291,6 @@ describe('BookCoverCard — placeholder state', () => {
|
||||
expect(imgs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not show BookCoverPlaceholder when hasCover is true', () => {
|
||||
const wrapper = mountCard(presentBookWithCover)
|
||||
expect(wrapper.findComponent({ name: 'BookCoverPlaceholder' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders cover img when hasCover is true', () => {
|
||||
const wrapper = mountCard(presentBookWithCover)
|
||||
const imgs = wrapper.findAll('img')
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('CollapsedSeriesCard', () => {
|
||||
|
||||
expect(wrapper.findAll('[data-testid="series-cover-tile"]')).toHaveLength(1)
|
||||
expect(wrapper.findAll('[data-testid="series-cover-fallback"]')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.book-cover-placeholder')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.book-cover-placeholder')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows a single fallback tile when coverBookIds is empty', () => {
|
||||
@@ -199,7 +199,7 @@ describe('CollapsedSeriesCard', () => {
|
||||
await firstImg.trigger('error')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.findAll('.book-cover-placeholder')).toHaveLength(1)
|
||||
expect(wrapper.findAll('.book-cover-placeholder')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('uses at most 4 cover tiles even if more are provided', () => {
|
||||
|
||||
@@ -135,12 +135,6 @@ describe('BookCarousel', () => {
|
||||
const book = makeBook({ hasCover: true })
|
||||
const wrapper = mountCarousel([book])
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render placeholder for audiobook recommendations that have a cover', () => {
|
||||
const wrapper = mountCarousel([makeBook({ hasCover: true, isAudiobook: true })])
|
||||
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders BookCoverPlaceholder when book.hasCover is false', () => {
|
||||
@@ -197,7 +191,6 @@ describe('BookCarousel', () => {
|
||||
|
||||
await wrapper.setProps({ books: [makeBook({ id: 42, hasCover: true, title: 'Retried' })] })
|
||||
expect(wrapper.find('[data-book-id="42"] img').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders centered cover with blurred backdrop after non-audiobook cover image loads', async () => {
|
||||
@@ -212,7 +205,6 @@ describe('BookCarousel', () => {
|
||||
expect(backdrop?.classes()).toContain('blur-md')
|
||||
expect(backdrop?.classes()).toContain('object-cover')
|
||||
expect(cover?.classes()).toContain('object-contain')
|
||||
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders audiobook covers as square without blurred backdrop', async () => {
|
||||
@@ -282,7 +274,7 @@ describe('BookCarousel', () => {
|
||||
const wrapper = mountCarousel(books)
|
||||
expect(wrapper.findAll('[data-book-id]').length).toBe(3)
|
||||
expect(wrapper.findAll('img').length).toBe(2)
|
||||
expect(wrapper.findAll('[data-testid="placeholder"]').length).toBe(1)
|
||||
expect(wrapper.findAll('[data-testid="placeholder"]').length).toBe(3)
|
||||
})
|
||||
|
||||
it('uses book id as seed when title is null', () => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ChevronRight, LibraryBig } from 'lucide-vue-next'
|
||||
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
|
||||
import BookCoverSurface from '../BookCoverSurface.vue'
|
||||
import type { ColumnId } from '@/features/book/composables/useTableColumns'
|
||||
|
||||
const props = defineProps<{
|
||||
book: BookCard
|
||||
colId: ColumnId
|
||||
}>()
|
||||
|
||||
const collapsed = computed(() => props.book.collapsedSeries!)
|
||||
const seriesName = computed(() => props.book.seriesName ?? '')
|
||||
const readCount = computed(() => collapsed.value.readCount)
|
||||
const bookCount = computed(() => collapsed.value.bookCount)
|
||||
const coverIds = computed(() => collapsed.value.coverBookIds.filter((id) => id > 0).slice(0, 4))
|
||||
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
|
||||
|
||||
function thumbnailUrl(bookId: number): string {
|
||||
return `/api/v1/books/${bookId}/thumbnail`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="colId === 'lockRow'" class="flex h-6 w-6 items-center justify-center">
|
||||
<LibraryBig :size="13" class="text-muted-foreground/60" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="colId === 'readStatus'" class="flex w-full items-center px-1 py-0.5">
|
||||
<div class="flex w-3 items-center justify-center">
|
||||
<span class="text-[10px] text-muted-foreground/40">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="colId === 'read'" class="flex w-full items-center justify-start">
|
||||
<div class="mr-auto flex h-7 w-7 items-center justify-center">
|
||||
<span class="text-[10px] text-muted-foreground/40">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="colId === 'cover'" class="flex h-9 items-center">
|
||||
<!-- Cover row: up to 4 thumbnails -->
|
||||
<div class="flex shrink-0 gap-0.5 min-w-9" :class="coverIds.length <= 1 ? 'justify-center' : 'justify-start'">
|
||||
<BookCoverSurface
|
||||
v-for="coverId in coverIds"
|
||||
:key="coverId"
|
||||
size="mini"
|
||||
:disable-spine="isAudiobook"
|
||||
class="h-8 w-6 rounded-sm overflow-hidden"
|
||||
>
|
||||
<img :src="thumbnailUrl(coverId)" class="h-full w-full object-cover" loading="lazy" alt="" />
|
||||
</BookCoverSurface>
|
||||
<BookCoverSurface
|
||||
v-if="coverIds.length === 0"
|
||||
size="mini"
|
||||
:disable-spine="isAudiobook"
|
||||
class="h-8 w-6 rounded-sm bg-muted flex items-center justify-center"
|
||||
>
|
||||
<span class="text-[8px] text-muted-foreground">?</span>
|
||||
</BookCoverSurface>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="colId === 'title'" class="flex items-center gap-2 min-w-0 h-full px-1">
|
||||
<span class="font-medium text-sm truncate block">{{ seriesName }}</span>
|
||||
<span class="text-xs text-muted-foreground shrink-0 mt-0.5">({{ bookCount }} {{ bookCount === 1 ? 'book' : 'books' }})</span>
|
||||
<span v-if="readCount > 0" class="text-xs text-muted-foreground shrink-0 mt-0.5">· {{ readCount }} of {{ bookCount }} read</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="colId === 'actions'" class="flex h-full w-6 items-center justify-center">
|
||||
<ChevronRight :size="16" class="text-muted-foreground/60 group-hover:text-foreground transition-colors" />
|
||||
</div>
|
||||
|
||||
<!-- Dash for all other columns -->
|
||||
<div v-else class="flex h-full items-center">
|
||||
<span class="text-[10px] text-muted-foreground/20">-</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,71 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
|
||||
import BookCoverSurface from '../BookCoverSurface.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
book: BookCard
|
||||
colspan: number
|
||||
selectionMode?: boolean
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const collapsed = computed(() => props.book.collapsedSeries!)
|
||||
const seriesName = computed(() => props.book.seriesName ?? '')
|
||||
const readCount = computed(() => collapsed.value.readCount)
|
||||
const bookCount = computed(() => collapsed.value.bookCount)
|
||||
const coverIds = computed(() => collapsed.value.coverBookIds.filter((id) => id > 0).slice(0, 4))
|
||||
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
|
||||
|
||||
function handleClick() {
|
||||
if (props.selectionMode) return
|
||||
router.push({ name: 'series-detail', params: { seriesName: seriesName.value } })
|
||||
}
|
||||
|
||||
function thumbnailUrl(bookId: number): string {
|
||||
return `/api/v1/books/${bookId}/thumbnail`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td :colspan="colspan" role="gridcell" class="cursor-pointer px-3 py-1.5" @click="handleClick">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<!-- Cover row: up to 4 thumbnails -->
|
||||
<div class="flex shrink-0 gap-0.5">
|
||||
<BookCoverSurface
|
||||
v-for="coverId in coverIds"
|
||||
:key="coverId"
|
||||
size="mini"
|
||||
:disable-spine="isAudiobook"
|
||||
class="h-8 w-6 rounded-sm overflow-hidden"
|
||||
>
|
||||
<img :src="thumbnailUrl(coverId)" class="h-full w-full object-cover" loading="lazy" alt="" />
|
||||
</BookCoverSurface>
|
||||
<BookCoverSurface
|
||||
v-if="coverIds.length === 0"
|
||||
size="mini"
|
||||
:disable-spine="isAudiobook"
|
||||
class="h-8 w-6 rounded-sm bg-muted flex items-center justify-center"
|
||||
>
|
||||
<span class="text-[8px] text-muted-foreground">?</span>
|
||||
</BookCoverSurface>
|
||||
</div>
|
||||
|
||||
<!-- Series info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="font-medium text-sm truncate block">{{ seriesName }}</span>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span class="text-xs text-muted-foreground"> {{ bookCount }} {{ bookCount === 1 ? 'book' : 'books' }} </span>
|
||||
<span v-if="readCount > 0" class="text-xs text-muted-foreground"> · {{ readCount }} of {{ bookCount }} read </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<ChevronRight :size="14" class="shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import BookTableCollapsedSeriesRow from '../BookTableCollapsedSeriesRow.vue'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
|
||||
const mockRouterPush = vi.fn<(...args: unknown[]) => unknown>()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
}))
|
||||
|
||||
function makeBook(overrides: Partial<BookCard> = {}): BookCard {
|
||||
return {
|
||||
id: 1,
|
||||
status: 'present',
|
||||
title: 'Book One',
|
||||
authors: ['Author A'],
|
||||
seriesName: 'The Arc',
|
||||
seriesIndex: 1,
|
||||
files: [],
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
genres: [],
|
||||
tags: [],
|
||||
rating: null,
|
||||
readingProgress: null,
|
||||
readStatus: null,
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: null,
|
||||
metadataScore: null,
|
||||
hasCover: false,
|
||||
hasMetadataLocks: false,
|
||||
lockedFields: [],
|
||||
subtitle: null,
|
||||
publisher: null,
|
||||
pageCount: null,
|
||||
isbn13: null,
|
||||
narrators: [],
|
||||
collapsedSeries: {
|
||||
bookCount: 5,
|
||||
readCount: 2,
|
||||
coverBookIds: [10, 20, 30, 40],
|
||||
seriesLatestAddedAt: null,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const { bookSpineOverlay } = useDisplaySettings()
|
||||
|
||||
describe('BookTableCollapsedSeriesRow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
bookSpineOverlay.value = 'off'
|
||||
})
|
||||
|
||||
it('renders the series name and book count', () => {
|
||||
const wrapper = mount(BookTableCollapsedSeriesRow, {
|
||||
props: { book: makeBook(), colspan: 6 },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('The Arc')
|
||||
expect(wrapper.text()).toContain('5 books')
|
||||
})
|
||||
|
||||
it('renders the read count when some books are read', () => {
|
||||
const wrapper = mount(BookTableCollapsedSeriesRow, {
|
||||
props: { book: makeBook(), colspan: 6 },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('2 of 5 read')
|
||||
})
|
||||
|
||||
it('does not render the read count when no books are read', () => {
|
||||
const wrapper = mount(BookTableCollapsedSeriesRow, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: { bookCount: 5, readCount: 0, coverBookIds: [10, 20], seriesLatestAddedAt: null },
|
||||
}),
|
||||
colspan: 6,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('0 of 5 read')
|
||||
})
|
||||
|
||||
it('renders up to four cover thumbnails', () => {
|
||||
const wrapper = mount(BookTableCollapsedSeriesRow, {
|
||||
props: { book: makeBook(), colspan: 6 },
|
||||
})
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(4)
|
||||
expect(images.map((image) => image.attributes('src'))).toEqual([
|
||||
'/api/v1/books/10/thumbnail',
|
||||
'/api/v1/books/20/thumbnail',
|
||||
'/api/v1/books/30/thumbnail',
|
||||
'/api/v1/books/40/thumbnail',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders a placeholder when no cover ids are available', () => {
|
||||
const wrapper = mount(BookTableCollapsedSeriesRow, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: { bookCount: 5, readCount: 0, coverBookIds: [], seriesLatestAddedAt: null },
|
||||
}),
|
||||
colspan: 6,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('img')).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('?')
|
||||
})
|
||||
|
||||
it('navigates to the series detail route on click', async () => {
|
||||
const wrapper = mount(BookTableCollapsedSeriesRow, {
|
||||
props: { book: makeBook(), colspan: 6 },
|
||||
})
|
||||
|
||||
await wrapper.get('td').trigger('click')
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith({
|
||||
name: 'series-detail',
|
||||
params: { seriesName: 'The Arc' },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders no more than four thumbnails even when more cover ids are provided', () => {
|
||||
const wrapper = mount(BookTableCollapsedSeriesRow, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
collapsedSeries: { bookCount: 6, readCount: 1, coverBookIds: [10, 20, 30, 40, 50, 60], seriesLatestAddedAt: null },
|
||||
}),
|
||||
colspan: 6,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('img')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('sets the expected colspan on the table cell', () => {
|
||||
const wrapper = mount(BookTableCollapsedSeriesRow, {
|
||||
props: { book: makeBook(), colspan: 9 },
|
||||
})
|
||||
|
||||
expect(wrapper.get('td').attributes('colspan')).toBe('9')
|
||||
})
|
||||
|
||||
it('does not navigate when selectionMode is true', async () => {
|
||||
const wrapper = mount(BookTableCollapsedSeriesRow, {
|
||||
props: { book: makeBook(), colspan: 6, selectionMode: true },
|
||||
})
|
||||
|
||||
await wrapper.get('td').trigger('click')
|
||||
|
||||
expect(mockRouterPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forces spine overlay off for audiobook collapsed rows', () => {
|
||||
bookSpineOverlay.value = 'strong'
|
||||
const wrapper = mount(BookTableCollapsedSeriesRow, {
|
||||
props: {
|
||||
book: makeBook({
|
||||
files: [{ id: 9, format: 'm4b', role: 'primary', sizeBytes: null }],
|
||||
}),
|
||||
colspan: 6,
|
||||
},
|
||||
})
|
||||
|
||||
const cover = wrapper.find('.book-cover-surface')
|
||||
expect(cover.attributes('data-cover-spine')).toBe('off')
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, ref, useAttrs } from 'vue'
|
||||
import { Aperture, BookMarked, ChevronLeft, ChevronRight, ListOrdered, RefreshCw, Shuffle, Sparkles } from 'lucide-vue-next'
|
||||
|
||||
import type { BookCard, ScrollerType } from '@bookorbit/types'
|
||||
import { isAudioFormat, type BookCard, type CoverAspectRatio, type ScrollerType } from '@bookorbit/types'
|
||||
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
|
||||
import BookQuickView from '@/features/book/components/BookQuickView.vue'
|
||||
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
|
||||
@@ -40,6 +40,8 @@ const typeIcon = computed(() => {
|
||||
})
|
||||
|
||||
const SKELETONS = Array.from({ length: 8 })
|
||||
const DEFAULT_COVER_WIDTH_CLASS = 'w-[120px]'
|
||||
const SQUARE_AUDIOBOOK_COVER_WIDTH_CLASS = 'w-[150px]'
|
||||
|
||||
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
|
||||
|
||||
@@ -74,6 +76,18 @@ function handleBookAction(book: BookCard, action: BookActionType) {
|
||||
promptDelete(book.id)
|
||||
}
|
||||
}
|
||||
|
||||
function isAudiobookCard(book: BookCard): boolean {
|
||||
return book.files.some((file) => (file.format ? isAudioFormat(file.format) : false))
|
||||
}
|
||||
|
||||
function coverWidthClass(book: BookCard): string {
|
||||
return isAudiobookCard(book) ? SQUARE_AUDIOBOOK_COVER_WIDTH_CLASS : DEFAULT_COVER_WIDTH_CLASS
|
||||
}
|
||||
|
||||
function coverAspectRatio(book: BookCard): CoverAspectRatio {
|
||||
return isAudiobookCard(book) ? '1/1' : '2/3'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -139,15 +153,16 @@ function handleBookAction(book: BookCard, action: BookActionType) {
|
||||
</div>
|
||||
|
||||
<!-- Books row -->
|
||||
<div v-else ref="scrollEl" class="flex gap-5 overflow-x-auto px-5 pb-5 pt-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div v-else ref="scrollEl" class="flex items-end gap-5 overflow-x-auto px-5 pb-5 pt-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div
|
||||
v-for="(book, index) in books"
|
||||
:key="book.id"
|
||||
class="w-[120px] shrink-0"
|
||||
class="shrink-0"
|
||||
:class="coverWidthClass(book)"
|
||||
style="animation: dashboardFadeUp 0.35s ease both"
|
||||
:style="{ animationDelay: `${index * 35}ms` }"
|
||||
>
|
||||
<BookCoverCard :book="book" @action="handleBookAction(book, $event)" />
|
||||
<BookCoverCard :book="book" :cover-aspect-ratio="coverAspectRatio(book)" @action="handleBookAction(book, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -382,7 +382,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex-1 overflow-y-auto p-4 md:p-6">
|
||||
<main class="flex-1 overflow-y-auto py-2">
|
||||
<div class="mb-4">
|
||||
<button class="flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground" @click="goBack">
|
||||
<ChevronLeft :size="16" />
|
||||
@@ -462,7 +462,7 @@ watch(
|
||||
@click="editSeriesMetadata"
|
||||
>
|
||||
<Pencil :size="14" />
|
||||
{{ openingSeriesEditor ? 'Preparing editor...' : 'Edit Series Metadata' }}
|
||||
{{ openingSeriesEditor ? 'Preparing editor...' : 'Edit Metadata' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -538,7 +538,7 @@ watch(
|
||||
<!-- Books section -->
|
||||
<section v-if="seriesInfo" class="rounded-lg border border-border/70 bg-card/60 p-3">
|
||||
<div
|
||||
class="sticky top-0 z-20 -mx-3 mb-3 border-b border-border/60 bg-card/92 px-3 pb-3 pt-2 backdrop-blur supports-[backdrop-filter]:bg-card/78"
|
||||
class="sticky top-0 z-20 -mx-3 mb-3 border-b border-border/60 bg-card/92 px-3 pb-3 pt-1 backdrop-blur supports-backdrop-filter:bg-card/78"
|
||||
>
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<h2 class="text-sm font-semibold text-foreground">Books</h2>
|
||||
|
||||
Reference in New Issue
Block a user