fix(client): bust stale covers after bulk refresh via pre-bump and consistent coverUrl usage

This commit is contained in:
neon
2026-05-13 11:35:21 -06:00
parent 7f9a4717a5
commit c7a066e4eb
15 changed files with 557 additions and 30 deletions
+4 -4
View File
@@ -16,7 +16,7 @@ A self-hosted library management and reading platform for ebooks, PDFs, audioboo
-->
[![Website](https://img.shields.io/badge/Website-bookorbit.app-blue?style=flat&logo=googlechrome&logoColor=white)](https://bookorbit.app)
[![Demo](https://img.shields.io/badge/Demo-live-brightgreen?style=flat&logo=rocket&logoColor=white)](https://demo.bookorbit.app)
[![Demo](https://img.shields.io/badge/Demo-live-brightgreen?style=flat&logo=rocket&logoColor=white)](https://demo.bookorbit.app/magic?token=2d92cb900e184cf0eb8b11f72cffc6011673d1016e1b300d750eb3d76abc1572)
[![Discussions](https://img.shields.io/badge/Discussions-GitHub-333?style=flat&logo=github&logoColor=white)](https://github.com/bookorbit/bookorbit/discussions)
[![Contributing](https://img.shields.io/badge/Contributing-guide-orange?style=flat&logo=handshake&logoColor=white)](https://github.com/bookorbit/bookorbit/blob/main/docs/CONTRIBUTING.md)
[![GHCR](https://img.shields.io/badge/GHCR-bookorbit/bookorbit-blue?style=flat&logo=docker&logoColor=white)](https://github.com/bookorbit/bookorbit/pkgs/container/bookorbit)
@@ -64,11 +64,11 @@ BookOrbit is a self-hosted digital library and reading platform. Organize and re
## Live Demo
Try BookOrbit without installing anything.
Explore BookOrbit instantly, no installation or account required.
**[Open Demo](https://demo.bookorbit.app/magic?token=2d92cb900e184cf0eb8b11f72cffc6011673d1016e1b300d750eb3d76abc1572)** - no account needed, the link logs you in automatically.
**[🚀 Launch Demo](https://demo.bookorbit.app/magic?token=2d92cb900e184cf0eb8b11f72cffc6011673d1016e1b300d750eb3d76abc1572)**
The demo is pre-loaded with a sample library of public domain books and resets automatically.
Note: The demo includes a sample library of public domain books. Some features are limited in the public demo, self-hosting BookOrbit provides the full experience.
---
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import type { BookCard } from '@bookorbit/types'
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { useCoverVersions } from '../composables/useCoverVersions'
const props = defineProps<{
book: BookCard
@@ -12,6 +13,7 @@ const props = defineProps<{
const route = useRoute()
const router = useRouter()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const { coverUrl } = useCoverVersions()
const collapsed = computed(() => props.book.collapsedSeries!)
const seriesName = computed(() => props.book.seriesName ?? '')
@@ -31,10 +33,6 @@ function handleClick() {
router.push({ name: 'series-detail', params: { seriesName: seriesName.value }, query: { from: route.fullPath } })
}
function thumbnailUrl(bookId: number): string {
return `/api/v1/books/${bookId}/thumbnail`
}
function tileClass(index: number): string {
if (tileCount.value <= 1) return 'col-span-2 row-span-2'
if (tileCount.value === 2) return 'row-span-2'
@@ -56,7 +54,7 @@ function tileClass(index: number): string {
<div class="relative overflow-hidden" :class="tileClass(i)" data-testid="series-cover-tile">
<img
v-if="!failedCovers.has(bookId)"
:src="thumbnailUrl(bookId)"
:src="coverUrl(bookId)"
class="absolute inset-0 h-full w-full object-cover"
loading="lazy"
alt=""
@@ -12,6 +12,23 @@ const mocks = vi.hoisted(() => ({
exportBooks: vi.fn<(bookIds: number[], includeAll?: boolean, formatGroup?: string) => Promise<void>>(),
}))
function makeSseStream(lines: string[]): { ok: true; body: { getReader: () => { read: () => Promise<{ done: boolean; value?: Uint8Array }> } } } {
const encoder = new TextEncoder()
let index = 0
return {
ok: true,
body: {
getReader: () => ({
async read() {
if (index >= lines.length) return { done: true as const, value: undefined }
const line = lines[index++]
return { done: false as const, value: encoder.encode(line + '\n') }
},
}),
},
}
}
vi.mock('@/lib/api', () => ({
api: mocks.api,
}))
@@ -396,4 +413,122 @@ describe('useBookBulkActions', () => {
expect(onDeleted).toHaveBeenCalledWith([])
expect(mocks.toastSuccess).toHaveBeenCalledWith('Deleted 500 books')
})
it('bumps versions for all selected IDs after SSE stream even when no events are received', async () => {
mocks.api.mockResolvedValue(makeSseStream([]))
mocks.bumpVersion.mockReset()
const selectedIds = ref(new Set([5, 6, 7]))
const { handleBulkRefreshMetadata } = useBookBulkActions(selectedIds, vi.fn())
await handleBulkRefreshMetadata()
expect(mocks.bumpVersion).toHaveBeenCalledWith(5)
expect(mocks.bumpVersion).toHaveBeenCalledWith(6)
expect(mocks.bumpVersion).toHaveBeenCalledWith(7)
})
it('bumps version per SSE event and not before the stream for bulk metadata refresh', async () => {
const order: string[] = []
mocks.bumpVersion.mockImplementation((id: number) => order.push(`bump:${id}`))
mocks.api.mockImplementation(async () => {
order.push('api:called')
return makeSseStream([
`data: ${JSON.stringify({ bookId: 10, success: true })}`,
`data: ${JSON.stringify({ bookId: 20, success: true })}`,
`data: ${JSON.stringify({ done: true, processed: 2, failed: 0 })}`,
])
})
const selectedIds = ref(new Set([10, 20]))
const { handleBulkRefreshMetadata } = useBookBulkActions(selectedIds, vi.fn())
await handleBulkRefreshMetadata()
expect(order.indexOf('api:called')).toBeLessThan(order.indexOf('bump:10'))
expect(order.indexOf('api:called')).toBeLessThan(order.indexOf('bump:20'))
})
it('bumps version exactly once per book when all SSE events are received for bulk metadata refresh', async () => {
mocks.api.mockResolvedValue(
makeSseStream([
`data: ${JSON.stringify({ bookId: 10, success: true })}`,
`data: ${JSON.stringify({ bookId: 20, success: true })}`,
`data: ${JSON.stringify({ done: true, processed: 2, failed: 0 })}`,
]),
)
mocks.bumpVersion.mockReset()
const selectedIds = ref(new Set([10, 20]))
const { handleBulkRefreshMetadata } = useBookBulkActions(selectedIds, vi.fn())
await handleBulkRefreshMetadata()
expect(mocks.bumpVersion.mock.calls.filter((c) => c[0] === 10).length).toBe(1)
expect(mocks.bumpVersion.mock.calls.filter((c) => c[0] === 20).length).toBe(1)
})
it('bumps versions for books that receive no SSE event in finally for bulk metadata refresh', async () => {
mocks.api.mockResolvedValue(
makeSseStream([`data: ${JSON.stringify({ bookId: 10, success: true })}`, `data: ${JSON.stringify({ done: true, processed: 1, failed: 0 })}`]),
)
mocks.bumpVersion.mockReset()
const selectedIds = ref(new Set([10, 20]))
const { handleBulkRefreshMetadata } = useBookBulkActions(selectedIds, vi.fn())
await handleBulkRefreshMetadata()
expect(mocks.bumpVersion).toHaveBeenCalledWith(10)
expect(mocks.bumpVersion).toHaveBeenCalledWith(20)
expect(mocks.bumpVersion.mock.calls.filter((c) => c[0] === 10).length).toBe(1)
expect(mocks.bumpVersion.mock.calls.filter((c) => c[0] === 20).length).toBe(1)
})
it('does not bump for query-selection books that received no SSE event', async () => {
mocks.api.mockResolvedValue(
makeSseStream([`data: ${JSON.stringify({ bookId: 42, success: true })}`, `data: ${JSON.stringify({ done: true, processed: 1, failed: 0 })}`]),
)
mocks.bumpVersion.mockReset()
const selectedIds = ref(new Set<number>())
const querySelection = ref<QuerySelectionState | null>(makeQuerySelection())
const { handleBulkRefreshMetadata } = useBookBulkActions(selectedIds, vi.fn(), undefined, undefined, querySelection)
await handleBulkRefreshMetadata()
expect(mocks.bumpVersion).toHaveBeenCalledTimes(1)
expect(mocks.bumpVersion).toHaveBeenCalledWith(42)
})
it('bumps version per SSE event and bumps missed books in finally for bulk re-extract cover', async () => {
mocks.api.mockResolvedValue(
makeSseStream([`data: ${JSON.stringify({ bookId: 30, updated: true })}`, `data: ${JSON.stringify({ done: true, processed: 2, updated: 1 })}`]),
)
mocks.bumpVersion.mockReset()
const selectedIds = ref(new Set([30, 40]))
const { handleBulkReExtractCover } = useBookBulkActions(selectedIds, vi.fn())
await handleBulkReExtractCover()
expect(mocks.bumpVersion).toHaveBeenCalledWith(30)
expect(mocks.bumpVersion).toHaveBeenCalledWith(40)
expect(mocks.bumpVersion.mock.calls.filter((c) => c[0] === 30).length).toBe(1)
expect(mocks.bumpVersion.mock.calls.filter((c) => c[0] === 40).length).toBe(1)
})
it('bumps all re-extract IDs in finally when stream delivers no events', async () => {
mocks.api.mockResolvedValue(makeSseStream([]))
mocks.bumpVersion.mockReset()
const selectedIds = ref(new Set([11, 22]))
const { handleBulkReExtractCover } = useBookBulkActions(selectedIds, vi.fn())
await handleBulkReExtractCover()
expect(mocks.bumpVersion).toHaveBeenCalledWith(11)
expect(mocks.bumpVersion).toHaveBeenCalledWith(22)
})
})
@@ -0,0 +1,281 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import type { BookCard } from '@bookorbit/types'
const mockSelectionMode = { value: false }
const mockSelectedIds = { value: new Set<number>() }
const mocks = vi.hoisted(() => ({
enterSelectionMode: vi.fn<() => void>(),
exitSelectionMode: vi.fn<() => void>(),
toggleBook: vi.fn<(id: number) => void>(),
promptDelete: vi.fn<(id: number) => void>(),
cancelDelete: vi.fn<() => void>(),
confirmDelete: vi.fn<() => void>(),
useBookBulkActions: vi.fn<(...args: unknown[]) => object>(() => ({
handleBulkRefreshMetadata: vi.fn<() => void>(),
handleBulkReExtractCover: vi.fn<() => void>(),
handleBulkSetStatus: vi.fn<() => void>(),
handleBulkSetRating: vi.fn<() => void>(),
handleBulkSetField: vi.fn<() => void>(),
handleBulkSetMetadataLock: vi.fn<() => void>(),
handleBulkUpdateTags: vi.fn<() => void>(),
handleDeleteSelected: vi.fn<() => void>(),
handleDownloadFiles: vi.fn<() => void>(),
inFlight: { value: null },
})),
useDeleteBookCallback: null as ((id: number) => void) | null,
}))
vi.mock('../useTableViewControls', () => ({
useTableViewControls: () => ({ tableRef: { value: null } }),
}))
vi.mock('../useBookViewSelection', () => ({
useBookViewSelection: () => ({
selectedIds: mockSelectedIds,
selectionMode: mockSelectionMode,
enterSelectionMode: mocks.enterSelectionMode,
exitSelectionMode: mocks.exitSelectionMode,
toggleBook: mocks.toggleBook,
handleSelect: vi.fn<() => void>(),
toggleSelectionMode: vi.fn<() => void>(),
}),
}))
vi.mock('../useDeleteBook', () => ({
useDeleteBook: (cb: (id: number) => void) => {
mocks.useDeleteBookCallback = cb
return {
pendingId: { value: null },
deleting: { value: false },
promptDelete: mocks.promptDelete,
cancelDelete: mocks.cancelDelete,
confirmDelete: mocks.confirmDelete,
}
},
}))
vi.mock('../useBookBulkActions', () => ({
useBookBulkActions: mocks.useBookBulkActions,
}))
import { useBookTableShell } from '../useBookTableShell'
function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'present',
title: 'Test Book',
authors: ['Test Author'],
seriesName: null,
seriesIndex: null,
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: [],
...overrides,
}
}
describe('useBookTableShell', () => {
beforeEach(() => {
mockSelectionMode.value = false
mockSelectedIds.value = new Set()
mocks.enterSelectionMode.mockClear()
mocks.exitSelectionMode.mockClear()
mocks.toggleBook.mockClear()
mocks.promptDelete.mockClear()
mocks.cancelDelete.mockClear()
mocks.confirmDelete.mockClear()
mocks.useBookBulkActions.mockClear()
})
it('wires useBookBulkActions without an onBulkRefreshCompleted callback', () => {
const books = ref([makeBook()])
useBookTableShell({ books })
expect(mocks.useBookBulkActions).toHaveBeenCalledOnce()
const call = mocks.useBookBulkActions.mock.calls[0]!
const [, , , fourthArg] = call
expect(fourthArg).toBeUndefined()
})
it('passes querySelection to useBookBulkActions', () => {
const books = ref([makeBook()])
const querySelection = ref(null)
useBookTableShell({ books, querySelection })
const call = mocks.useBookBulkActions.mock.calls[0]!
const [, , , , fifthArg] = call
expect(fifthArg).toBe(querySelection)
})
it('passes the books ref to useBookBulkActions', () => {
const books = ref([makeBook()])
useBookTableShell({ books })
const call = mocks.useBookBulkActions.mock.calls[0]!
const [, , thirdArg] = call
expect(thirdArg).toBe(books)
})
describe('handleBookAction', () => {
it('sets quickViewBookId and opens quick view for quick-view action', () => {
const books = ref([makeBook({ id: 7 })])
const { handleBookAction, quickViewBookId, quickViewOpen } = useBookTableShell({ books })
handleBookAction(makeBook({ id: 7 }), 'quick-view')
expect(quickViewBookId.value).toBe(7)
expect(quickViewOpen.value).toBe(true)
})
it('enters selection mode and toggles the book for add-to-collection when not in selection mode', () => {
mockSelectionMode.value = false
const books = ref([makeBook({ id: 3 })])
const { handleBookAction, addToCollectionOpen } = useBookTableShell({ books })
handleBookAction(makeBook({ id: 3 }), 'add-to-collection')
expect(mocks.enterSelectionMode).toHaveBeenCalledOnce()
expect(mocks.toggleBook).toHaveBeenCalledWith(3)
expect(addToCollectionOpen.value).toBe(true)
})
it('skips enterSelectionMode for add-to-collection when already in selection mode', () => {
mockSelectionMode.value = true
const books = ref([makeBook({ id: 3 })])
const { handleBookAction, addToCollectionOpen } = useBookTableShell({ books })
handleBookAction(makeBook({ id: 3 }), 'add-to-collection')
expect(mocks.enterSelectionMode).not.toHaveBeenCalled()
expect(mocks.toggleBook).not.toHaveBeenCalled()
expect(addToCollectionOpen.value).toBe(true)
})
it('calls promptDelete for the delete action', () => {
const books = ref([makeBook({ id: 5 })])
const { handleBookAction } = useBookTableShell({ books })
handleBookAction(makeBook({ id: 5 }), 'delete')
expect(mocks.promptDelete).toHaveBeenCalledWith(5)
})
})
describe('handleTableBookUpdate', () => {
it('replaces the matching book in the list', () => {
const books = ref([makeBook({ id: 1, title: 'Original' }), makeBook({ id: 2, title: 'Other' })])
const { handleTableBookUpdate } = useBookTableShell({ books })
handleTableBookUpdate(makeBook({ id: 1, title: 'Updated' }))
expect(books.value[0]?.title).toBe('Updated')
expect(books.value[1]?.title).toBe('Other')
})
it('does not modify the list when the book id is not found', () => {
const books = ref([makeBook({ id: 1, title: 'Unchanged' })])
const { handleTableBookUpdate } = useBookTableShell({ books })
handleTableBookUpdate(makeBook({ id: 999, title: 'Ghost' }))
expect(books.value[0]?.title).toBe('Unchanged')
})
it('only replaces the first matching index when duplicate ids exist', () => {
const books = ref([makeBook({ id: 1, title: 'A' }), makeBook({ id: 1, title: 'B' })])
const { handleTableBookUpdate } = useBookTableShell({ books })
handleTableBookUpdate(makeBook({ id: 1, title: 'Updated' }))
expect(books.value[0]?.title).toBe('Updated')
expect(books.value[1]?.title).toBe('B')
})
})
describe('onDeleted single-book callback', () => {
it('removes the deleted book from the list', () => {
const books = ref([makeBook({ id: 10 }), makeBook({ id: 20 })])
useBookTableShell({ books })
mocks.useDeleteBookCallback!(10)
expect(books.value.map((b) => b.id)).toEqual([20])
})
it('leaves list unchanged when deleted id is not present', () => {
const books = ref([makeBook({ id: 10 })])
useBookTableShell({ books })
mocks.useDeleteBookCallback!(99)
expect(books.value.map((b) => b.id)).toEqual([10])
})
})
describe('bulk delete callback', () => {
it('removes all deleted books and exits selection mode', () => {
const books = ref([makeBook({ id: 1 }), makeBook({ id: 2 }), makeBook({ id: 3 })])
useBookTableShell({ books })
const call = mocks.useBookBulkActions.mock.calls[0]!
const [, bulkDeleteCallback] = call as [unknown, (ids: number[]) => void]
bulkDeleteCallback([1, 3])
expect(books.value.map((b) => b.id)).toEqual([2])
expect(mocks.exitSelectionMode).toHaveBeenCalledOnce()
})
it('exits selection mode even when ids array is empty', () => {
const books = ref([makeBook({ id: 1 }), makeBook({ id: 2 })])
useBookTableShell({ books })
const call = mocks.useBookBulkActions.mock.calls[0]!
const [, bulkDeleteCallback] = call as [unknown, (ids: number[]) => void]
bulkDeleteCallback([])
expect(books.value.map((b) => b.id)).toEqual([1, 2])
expect(mocks.exitSelectionMode).toHaveBeenCalledOnce()
})
})
it('exposes delete control refs and handlers', () => {
const books = ref([makeBook()])
const shell = useBookTableShell({ books })
expect(shell.deleteBookId).toBeDefined()
expect(shell.deletingBook).toBeDefined()
expect(shell.promptDelete).toBe(mocks.promptDelete)
expect(shell.cancelDelete).toBe(mocks.cancelDelete)
expect(shell.confirmDelete).toBe(mocks.confirmDelete)
})
it('exposes dialog open refs initialised to false/null', () => {
const books = ref([makeBook()])
const shell = useBookTableShell({ books })
expect(shell.addToCollectionOpen.value).toBe(false)
expect(shell.bulkTagsOpen.value).toBe(false)
expect(shell.sendBookOpen.value).toBe(false)
expect(shell.quickViewOpen.value).toBe(false)
expect(shell.quickViewBookId.value).toBeNull()
})
})
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('useCoverVersions', () => {
beforeEach(() => {
vi.resetModules()
localStorage.clear()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-01T10:00:00.000Z'))
})
afterEach(() => {
vi.useRealTimers()
})
async function load() {
const mod = await import('../useCoverVersions')
return mod.useCoverVersions()
}
it('returns base thumbnail URL when no version is stored', async () => {
const { coverUrl } = await load()
expect(coverUrl(42)).toBe('/api/v1/books/42/thumbnail')
})
it('returns base cover URL when type is cover and no version is stored', async () => {
const { coverUrl } = await load()
expect(coverUrl(42, 'cover')).toBe('/api/v1/books/42/cover')
})
it('returns versioned thumbnail URL after bumpVersion', async () => {
const { coverUrl, bumpVersion } = await load()
bumpVersion(42)
const now = Date.now()
expect(coverUrl(42)).toBe(`/api/v1/books/42/thumbnail?t=${now}`)
})
it('returns versioned cover URL after bumpVersion', async () => {
const { coverUrl, bumpVersion } = await load()
bumpVersion(7)
const now = Date.now()
expect(coverUrl(7, 'cover')).toBe(`/api/v1/books/7/cover?t=${now}`)
})
it('does not add ?t= to a different book that was not bumped', async () => {
const { coverUrl, bumpVersion } = await load()
bumpVersion(1)
expect(coverUrl(2)).toBe('/api/v1/books/2/thumbnail')
})
it('getVersion returns undefined before any bump', async () => {
const { getVersion } = await load()
expect(getVersion(99)).toBeUndefined()
})
it('getVersion returns the stored timestamp after bumpVersion', async () => {
const { getVersion, bumpVersion } = await load()
bumpVersion(5)
expect(getVersion(5)).toBe(Date.now())
})
it('bumpVersion with a later timestamp overwrites the earlier one', async () => {
const { coverUrl, bumpVersion } = await load()
bumpVersion(3)
const t1 = Date.now()
vi.advanceTimersByTime(1000)
bumpVersion(3)
const t2 = Date.now()
expect(t2).toBeGreaterThan(t1)
expect(coverUrl(3)).toBe(`/api/v1/books/3/thumbnail?t=${t2}`)
})
it('singleton: versions bumped in one composable instance are visible in another', async () => {
const mod = await import('../useCoverVersions')
const a = mod.useCoverVersions()
const b = mod.useCoverVersions()
a.bumpVersion(10)
expect(b.coverUrl(10)).toBe(`/api/v1/books/10/thumbnail?t=${Date.now()}`)
})
it('persists bumped version to localStorage', async () => {
const { bumpVersion } = await load()
bumpVersion(11)
const stored = JSON.parse(localStorage.getItem('cover-versions') ?? '[]') as [number, number][]
expect(stored).toContainEqual([11, Date.now()])
})
it('loads pre-existing versions from localStorage on init', async () => {
const ts = Date.now()
localStorage.setItem('cover-versions', JSON.stringify([[20, ts]]))
const { coverUrl } = await load()
expect(coverUrl(20)).toBe(`/api/v1/books/20/thumbnail?t=${ts}`)
})
it('returns empty map when localStorage contains corrupt data', async () => {
localStorage.setItem('cover-versions', 'not-json')
const { coverUrl } = await load()
expect(coverUrl(1)).toBe('/api/v1/books/1/thumbnail')
})
it('handles localStorage setItem quota errors silently', async () => {
const { bumpVersion, coverUrl } = await load()
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('QuotaExceededError')
})
expect(() => bumpVersion(7)).not.toThrow()
expect(coverUrl(7)).toBe(`/api/v1/books/7/thumbnail?t=${Date.now()}`)
})
})
@@ -189,6 +189,7 @@ export function useBookBulkActions(
for (const id of ids) {
if (refreshFeedback.getFeedback(id)?.state === 'refreshing') {
refreshFeedback.markFailed(id, 'Metadata refresh interrupted')
bumpVersion(id)
}
}
clearRefreshing(ids)
@@ -229,6 +230,7 @@ export function useBookBulkActions(
const decoder = new TextDecoder()
let processed = 0
let updated = 0
const bumpedIds = new Set<number>()
try {
while (true) {
const { done, value } = await reader.read()
@@ -238,6 +240,7 @@ export function useBookBulkActions(
try {
const data = JSON.parse(line.slice(6))
if (data.bookId !== undefined) {
bumpedIds.add(data.bookId)
bumpVersion(data.bookId)
clearRefreshing([data.bookId])
inFlight.value = { label: 'Re-extracting covers', processed: inFlight.value!.processed + 1, total }
@@ -252,6 +255,9 @@ export function useBookBulkActions(
}
}
} finally {
for (const id of ids) {
if (!bumpedIds.has(id)) bumpVersion(id)
}
clearRefreshing(ids)
inFlight.value = null
}
@@ -12,13 +12,12 @@ interface BookTableShellOptions {
total?: Ref<number>
loading?: Ref<boolean>
exitSelectionMode?: () => void
loadFn: (force?: boolean) => void | Promise<void>
querySelection?: Ref<QuerySelectionState | null>
}
type BookActionType = 'quick-view' | 'add-to-collection' | 'delete'
export function useBookTableShell({ books, loadFn, querySelection }: BookTableShellOptions) {
export function useBookTableShell({ books, querySelection }: BookTableShellOptions) {
const tableControls = useTableViewControls()
const selection = useBookViewSelection(books)
@@ -40,7 +39,7 @@ export function useBookTableShell({ books, loadFn, querySelection }: BookTableSh
selection.exitSelectionMode()
},
books,
() => loadFn(true),
undefined,
querySelection,
)
@@ -2,10 +2,12 @@
import { BookOpen, Play } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { useCurrentlyReadingWidget } from '../../composables/useCurrentlyReadingWidget'
const { data, loading, error } = useCurrentlyReadingWidget()
const router = useRouter()
const { coverUrl } = useCoverVersions()
function goToBook(bookId: number) {
void router.push({ name: 'book-detail', params: { bookId } })
@@ -61,12 +63,7 @@ function continueReading(bookId: number, fileId: number | null, fileFormat: stri
>
<!-- Cover thumbnail -->
<div class="h-14 w-9 shrink-0 overflow-hidden rounded shadow-sm">
<img
v-if="book.hasCover"
:src="`/api/v1/books/${book.bookId}/thumbnail`"
:alt="book.title ?? 'Book cover'"
class="h-full w-full object-cover"
/>
<img v-if="book.hasCover" :src="coverUrl(book.bookId)" :alt="book.title ?? 'Book cover'" class="h-full w-full object-cover" />
<div v-else class="flex h-full w-full items-center justify-center bg-muted text-muted-foreground">
<BookOpen :size="12" />
</div>
@@ -2,10 +2,12 @@
import { Highlighter, BookOpen, ExternalLink } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { useHighlightOfTheDayWidget } from '../../composables/useHighlightOfTheDayWidget'
const { data, loading, error } = useHighlightOfTheDayWidget()
const router = useRouter()
const { coverUrl } = useCoverVersions()
function goToBook() {
if (!data.value) return
@@ -46,7 +48,7 @@ function goToBook() {
</blockquote>
<button class="flex cursor-pointer items-center gap-2 rounded-lg pb-1 pl-1 text-left transition-colors hover:bg-muted/40" @click="goToBook">
<div v-if="data.hasCover" class="h-9 w-6 shrink-0 overflow-hidden rounded shadow-sm">
<img :src="`/api/v1/books/${data.bookId}/thumbnail`" :alt="data.bookTitle ?? 'Cover'" class="h-full w-full object-cover" />
<img :src="coverUrl(data.bookId)" :alt="data.bookTitle ?? 'Cover'" class="h-full w-full object-cover" />
</div>
<div v-else class="flex h-8 w-5 shrink-0 items-center justify-center rounded bg-muted">
<BookOpen :size="10" class="text-muted-foreground" />
@@ -2,10 +2,12 @@
import { Clock, BookOpen, Play } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { useLongWaitWidget } from '../../composables/useLongWaitWidget'
const { data, loading, error } = useLongWaitWidget()
const router = useRouter()
const { coverUrl } = useCoverVersions()
function goToBook() {
if (!data.value) return
@@ -54,7 +56,7 @@ function startReading() {
<div v-else class="flex flex-1 flex-col">
<div class="flex flex-1 items-center justify-center gap-4">
<button class="h-24 w-18 shrink-0 cursor-pointer overflow-hidden rounded shadow-sm transition-opacity hover:opacity-80" @click="goToBook">
<img v-if="data.hasCover" :src="`/api/v1/books/${data.bookId}/thumbnail`" :alt="data.title ?? 'Cover'" class="h-full w-full object-cover" />
<img v-if="data.hasCover" :src="coverUrl(data.bookId)" :alt="data.title ?? 'Cover'" class="h-full w-full object-cover" />
<div v-else class="flex h-full w-full items-center justify-center bg-muted">
<BookOpen :size="14" class="text-muted-foreground" />
</div>
@@ -3,12 +3,14 @@ import { Gem, Star, BookOpen, BookMarked, Check } from 'lucide-vue-next'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { useBookStatus } from '@/features/book/composables/useBookStatus'
import { useNeglectedGemsWidget } from '../../composables/useNeglectedGemsWidget'
const { data, loading, error } = useNeglectedGemsWidget()
const router = useRouter()
const { setStatus } = useBookStatus()
const { coverUrl } = useCoverVersions()
const displayIndex = ref(0)
const currentGem = computed(() => data.value?.gems[displayIndex.value] ?? null)
@@ -59,12 +61,7 @@ async function addToQueue() {
<!-- Gem -->
<div v-else-if="currentGem" class="flex flex-1 flex-col items-center justify-center gap-2">
<button class="h-19 w-13 cursor-pointer overflow-hidden rounded shadow-sm transition-opacity hover:opacity-80" @click="goToBook">
<img
v-if="currentGem.hasCover"
:src="`/api/v1/books/${currentGem.bookId}/thumbnail`"
:alt="currentGem.title ?? 'Cover'"
class="h-full w-full object-cover"
/>
<img v-if="currentGem.hasCover" :src="coverUrl(currentGem.bookId)" :alt="currentGem.title ?? 'Cover'" class="h-full w-full object-cover" />
<div v-else class="flex h-full w-full items-center justify-center bg-muted">
<BookOpen :size="14" class="text-muted-foreground" />
</div>
@@ -5,6 +5,7 @@ import { CalendarRange } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { UserReadingSessionTimeline, UserReadingSessionTimelineItem } from '@bookorbit/types'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { getFormatColor } from '@/features/book/lib/format-colors'
import { fetchUserReadingSessionTimeline, updateUserReadingSessionTimelineSession } from '../../api/statistics.api'
import { useStatisticsConfig } from '../../composables/useStatisticsConfig'
@@ -52,6 +53,7 @@ interface PointerOffset {
}
const { filters } = useStatisticsConfig()
const { coverUrl } = useCoverVersions()
const now = new Date()
const selectedYear = ref(getUtcIsoWeekYear(now))
@@ -214,7 +216,7 @@ function makeOption(data: SessionSegment[], preview: DragPreview | null) {
const title = escapeHtml(d.bookTitle ?? 'Reading session')
const format = escapeHtml(d.bookFormat ?? 'Unknown')
const cover = d.bookId
? `<img src="/api/v1/books/${d.bookId}/thumbnail" alt="Cover" style="width:32px;height:48px;object-fit:cover;border-radius:4px;" />`
? `<img src="${coverUrl(d.bookId)}" alt="Cover" style="width:32px;height:48px;object-fit:cover;border-radius:4px;" />`
: ''
return [
-1
View File
@@ -183,7 +183,6 @@ const {
handleTableBookUpdate,
} = useBookTableShell({
books,
loadFn: () => load(true),
})
const metadataExportOpen = ref(false)
-1
View File
@@ -406,7 +406,6 @@ const {
handleTableBookUpdate,
} = useBookTableShell({
books,
loadFn: () => load(true),
querySelection,
})
-1
View File
@@ -191,7 +191,6 @@ const {
handleTableBookUpdate,
} = useBookTableShell({
books,
loadFn: () => load(true),
})
const metadataExportOpen = ref(false)