mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(book-requests): show the book's cover on a request made from its page
Asking for a book's missing format sends no cover, so the request list, drawer and release picker drew a blank placeholder. Fall back to the cover of the book the request joins or became, in the requested format's slot when it has one. The request's own art still wins.
This commit is contained in:
@@ -106,6 +106,18 @@ describe('RequestListTable', () => {
|
|||||||
|
|
||||||
expect(titleButton?.text()).toBe(LONG_TITLE)
|
expect(titleButton?.text()).toBe(LONG_TITLE)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A format asked for from a book's page carries no art, and used to show a blank placeholder.
|
||||||
|
it('falls back to the cover of the book a format request joins', () => {
|
||||||
|
const wrapper = render(request({ status: 'approved', matchedBookId: null, targetBookId: 434, coverUrl: null }))
|
||||||
|
const covers = wrapper.findAllComponents({ name: 'RequestCover' })
|
||||||
|
|
||||||
|
expect(covers.length).toBeGreaterThan(0)
|
||||||
|
for (const cover of covers) {
|
||||||
|
expect(cover.props('src')).toBeNull()
|
||||||
|
expect(cover.props('fallbackSources')).toEqual(['/api/v1/books/434/thumbnail?medium=audio'])
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('RequestListTable pairs', () => {
|
describe('RequestListTable pairs', () => {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { requestBookCoverSources } from '../requestBookCover'
|
||||||
|
|
||||||
|
describe('requestBookCoverSources', () => {
|
||||||
|
it('offers nothing for a request that names no book', () => {
|
||||||
|
expect(requestBookCoverSources({ mediaKind: 'audiobook', matchedBookId: null, targetBookId: null })).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("asks the book a format request joins for that format's cover", () => {
|
||||||
|
expect(requestBookCoverSources({ mediaKind: 'audiobook', matchedBookId: null, targetBookId: 434 })).toEqual([
|
||||||
|
'/api/v1/books/434/thumbnail?medium=audio',
|
||||||
|
])
|
||||||
|
expect(requestBookCoverSources({ mediaKind: 'ebook', matchedBookId: null, targetBookId: 434 })).toEqual([
|
||||||
|
'/api/v1/books/434/thumbnail?medium=ebook',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers the book the request became over the one it was aimed at', () => {
|
||||||
|
expect(requestBookCoverSources({ mediaKind: 'audiobook', matchedBookId: 500, targetBookId: 434 })).toEqual([
|
||||||
|
'/api/v1/books/500/thumbnail?medium=audio',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -48,6 +48,7 @@ import RequestDrawerToolbar from './RequestDrawerToolbar.vue'
|
|||||||
import RequestEmptyState from './RequestEmptyState.vue'
|
import RequestEmptyState from './RequestEmptyState.vue'
|
||||||
import EntityNotFound from '@/components/EntityNotFound.vue'
|
import EntityNotFound from '@/components/EntityNotFound.vue'
|
||||||
import { formatKey, formatKeys, languageKey } from '../releaseFacets'
|
import { formatKey, formatKeys, languageKey } from '../releaseFacets'
|
||||||
|
import { requestBookCoverSources } from '../requestBookCover'
|
||||||
import { requestFailureText } from '../requestOutcome'
|
import { requestFailureText } from '../requestOutcome'
|
||||||
import { protocolChipClass, sourceChipClass } from '../sourceColors'
|
import { protocolChipClass, sourceChipClass } from '../sourceColors'
|
||||||
import { useBookRequestActions } from '../composables/useBookRequests'
|
import { useBookRequestActions } from '../composables/useBookRequests'
|
||||||
@@ -199,6 +200,7 @@ const busy = computed(() => grabbing.value || actions.isPending(request.value?.i
|
|||||||
const isRetry = computed(() => request.value?.status === 'failed')
|
const isRetry = computed(() => request.value?.status === 'failed')
|
||||||
const failureText = computed(() => (request.value ? requestFailureText(request.value, (key, named) => t(key, named)) : null))
|
const failureText = computed(() => (request.value ? requestFailureText(request.value, (key, named) => t(key, named)) : null))
|
||||||
const authorLine = computed(() => request.value?.authors.join(', ') ?? '')
|
const authorLine = computed(() => request.value?.authors.join(', ') ?? '')
|
||||||
|
const bookCoverSources = computed(() => (request.value ? requestBookCoverSources(request.value) : []))
|
||||||
const localOnlyCriteriaFacts = computed(() => (criteria.value ? localOnlyFacts(criteria.value) : []))
|
const localOnlyCriteriaFacts = computed(() => (criteria.value ? localOnlyFacts(criteria.value) : []))
|
||||||
/**
|
/**
|
||||||
* Adding a source or switching one back on happens under Settings > System > Requests, which is
|
* Adding a source or switching one back on happens under Settings > System > Requests, which is
|
||||||
@@ -789,7 +791,13 @@ function showEverySource(): void {
|
|||||||
<template v-else-if="request">
|
<template v-else-if="request">
|
||||||
<!-- The stage is named in the toolbar, so what is left here is the book being grabbed for. -->
|
<!-- The stage is named in the toolbar, so what is left here is the book being grabbed for. -->
|
||||||
<header class="flex items-center gap-3.5">
|
<header class="flex items-center gap-3.5">
|
||||||
<RequestCover :src="request.coverUrl" :media-kind="request.mediaKind" class="h-[72px] w-12 shadow-sm" :icon-size="20" />
|
<RequestCover
|
||||||
|
:src="request.coverUrl"
|
||||||
|
:fallback-sources="bookCoverSources"
|
||||||
|
:media-kind="request.mediaKind"
|
||||||
|
class="h-[72px] w-12 shadow-sm"
|
||||||
|
:icon-size="20"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h2 class="truncate text-base font-semibold tracking-tight text-foreground">{{ request.title }}</h2>
|
<h2 class="truncate text-base font-semibold tracking-tight text-foreground">{{ request.title }}</h2>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { prefetchRequestAttempts } from '../composables/useRequestAttempts'
|
|||||||
import { prefetchSeedStatus } from '../composables/useRequestSeedStatus'
|
import { prefetchSeedStatus } from '../composables/useRequestSeedStatus'
|
||||||
import { REQUEST_DRAWER } from '../requestDrawerContext'
|
import { REQUEST_DRAWER } from '../requestDrawerContext'
|
||||||
import { canCancelRequest, canDeleteRequest, canDismissRequest, canLeaveRequest, cancelStopsATransfer } from '../requestActions'
|
import { canCancelRequest, canDeleteRequest, canDismissRequest, canLeaveRequest, cancelStopsATransfer } from '../requestActions'
|
||||||
|
import { requestBookCoverSources } from '../requestBookCover'
|
||||||
import { requestMediaIcon } from '../requestMedia'
|
import { requestMediaIcon } from '../requestMedia'
|
||||||
import { requestFailureText } from '../requestOutcome'
|
import { requestFailureText } from '../requestOutcome'
|
||||||
import { localizedRequestActionFailure } from '../requestActionFailure'
|
import { localizedRequestActionFailure } from '../requestActionFailure'
|
||||||
@@ -117,6 +118,7 @@ const canFulfilThis = computed(
|
|||||||
const busy = computed(() => actions.isPending(request.value?.id))
|
const busy = computed(() => actions.isPending(request.value?.id))
|
||||||
const mediaIcon = computed(() => requestMediaIcon(request.value?.mediaKind ?? 'ebook'))
|
const mediaIcon = computed(() => requestMediaIcon(request.value?.mediaKind ?? 'ebook'))
|
||||||
const authorLine = computed(() => request.value?.authors.join(', ') ?? '')
|
const authorLine = computed(() => request.value?.authors.join(', ') ?? '')
|
||||||
|
const bookCoverSources = computed(() => (request.value ? requestBookCoverSources(request.value) : []))
|
||||||
/** Non-empty only while an attempt is held because its release turned out to hold several books. */
|
/** Non-empty only while an attempt is held because its release turned out to hold several books. */
|
||||||
const releaseUnits = computed(() => request.value?.download?.releaseUnits ?? [])
|
const releaseUnits = computed(() => request.value?.download?.releaseUnits ?? [])
|
||||||
const isHeld = computed(() => request.value?.status === 'needs_review')
|
const isHeld = computed(() => request.value?.status === 'needs_review')
|
||||||
@@ -679,7 +681,13 @@ function dockFileMeta(file: BookDockFile): string {
|
|||||||
<RequestPairSwitch v-if="companion" :request="request" :companion="companion" :progress-by-request="progressByRequest" />
|
<RequestPairSwitch v-if="companion" :request="request" :companion="companion" :progress-by-request="progressByRequest" />
|
||||||
|
|
||||||
<header class="flex items-start gap-4">
|
<header class="flex items-start gap-4">
|
||||||
<RequestCover :src="request.coverUrl" :media-kind="request.mediaKind" class="h-[105px] w-[70px] shadow-sm" :icon-size="24" />
|
<RequestCover
|
||||||
|
:src="request.coverUrl"
|
||||||
|
:fallback-sources="bookCoverSources"
|
||||||
|
:media-kind="request.mediaKind"
|
||||||
|
class="h-[105px] w-[70px] shadow-sm"
|
||||||
|
:icon-size="24"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1 space-y-1.5">
|
<div class="min-w-0 flex-1 space-y-1.5">
|
||||||
<h2 class="text-lg leading-snug font-semibold tracking-tight text-foreground">{{ request.title }}</h2>
|
<h2 class="text-lg leading-snug font-semibold tracking-tight text-foreground">{{ request.title }}</h2>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { formatPercent, formatRelativeFromNow } from '@/i18n/formatters'
|
|||||||
import { formatBytes } from '@/lib/formatting'
|
import { formatBytes } from '@/lib/formatting'
|
||||||
import type { RequestListDensity } from '../composables/useRequestListDensity'
|
import type { RequestListDensity } from '../composables/useRequestListDensity'
|
||||||
import { canBulkActRequest } from '../requestActions'
|
import { canBulkActRequest } from '../requestActions'
|
||||||
|
import { requestBookCoverSources } from '../requestBookCover'
|
||||||
import { requestMediaColor, requestMediaIcon } from '../requestMedia'
|
import { requestMediaColor, requestMediaIcon } from '../requestMedia'
|
||||||
import { orderedHalves, rowRequests } from '../requestPair'
|
import { orderedHalves, rowRequests } from '../requestPair'
|
||||||
import { requestFailureText, requestOutcome } from '../requestOutcome'
|
import { requestFailureText, requestOutcome } from '../requestOutcome'
|
||||||
@@ -257,6 +258,11 @@ function halfCover(row: BookRequestItem, kind: 'ebook' | 'audiobook'): string |
|
|||||||
return rowRequests(row).find((request) => request.mediaKind === kind)?.coverUrl ?? null
|
return rowRequests(row).find((request) => request.mediaKind === kind)?.coverUrl ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function halfBookCovers(row: BookRequestItem, kind: 'ebook' | 'audiobook'): string[] {
|
||||||
|
const half = rowRequests(row).find((request) => request.mediaKind === kind)
|
||||||
|
return half ? requestBookCoverSources(half) : []
|
||||||
|
}
|
||||||
|
|
||||||
/** The book either half landed as, so a pair that has partly arrived still links somewhere real. */
|
/** The book either half landed as, so a pair that has partly arrived still links somewhere real. */
|
||||||
function landedBookId(row: BookRequestItem): number | null {
|
function landedBookId(row: BookRequestItem): number | null {
|
||||||
return rowRequests(row).find((request) => request.status === 'available' && request.matchedBookId !== null)?.matchedBookId ?? null
|
return rowRequests(row).find((request) => request.status === 'available' && request.matchedBookId !== null)?.matchedBookId ?? null
|
||||||
@@ -389,12 +395,15 @@ function rowDismissed(row: BookRequestItem): boolean {
|
|||||||
v-if="isPair(request)"
|
v-if="isPair(request)"
|
||||||
:ebook-src="halfCover(request, 'ebook')"
|
:ebook-src="halfCover(request, 'ebook')"
|
||||||
:audio-src="halfCover(request, 'audiobook')"
|
:audio-src="halfCover(request, 'audiobook')"
|
||||||
|
:ebook-fallbacks="halfBookCovers(request, 'ebook')"
|
||||||
|
:audio-fallbacks="halfBookCovers(request, 'audiobook')"
|
||||||
:class="isCompact ? 'me-1 h-8 w-[22px]' : 'me-1.5 h-[46px] w-8'"
|
:class="isCompact ? 'me-1 h-8 w-[22px]' : 'me-1.5 h-[46px] w-8'"
|
||||||
:icon-size="isCompact ? 11 : 14"
|
:icon-size="isCompact ? 11 : 14"
|
||||||
/>
|
/>
|
||||||
<RequestCover
|
<RequestCover
|
||||||
v-else
|
v-else
|
||||||
:src="request.coverUrl"
|
:src="request.coverUrl"
|
||||||
|
:fallback-sources="requestBookCoverSources(request)"
|
||||||
:media-kind="request.mediaKind"
|
:media-kind="request.mediaKind"
|
||||||
:class="isCompact ? 'h-8 w-[22px]' : 'h-[46px] w-8'"
|
:class="isCompact ? 'h-8 w-[22px]' : 'h-[46px] w-8'"
|
||||||
:icon-size="isCompact ? 11 : 14"
|
:icon-size="isCompact ? 11 : 14"
|
||||||
@@ -622,10 +631,19 @@ function rowDismissed(row: BookRequestItem): boolean {
|
|||||||
v-if="isPair(request)"
|
v-if="isPair(request)"
|
||||||
:ebook-src="halfCover(request, 'ebook')"
|
:ebook-src="halfCover(request, 'ebook')"
|
||||||
:audio-src="halfCover(request, 'audiobook')"
|
:audio-src="halfCover(request, 'audiobook')"
|
||||||
|
:ebook-fallbacks="halfBookCovers(request, 'ebook')"
|
||||||
|
:audio-fallbacks="halfBookCovers(request, 'audiobook')"
|
||||||
class="me-1.5 h-[54px] w-9"
|
class="me-1.5 h-[54px] w-9"
|
||||||
:icon-size="16"
|
:icon-size="16"
|
||||||
/>
|
/>
|
||||||
<RequestCover v-else :src="request.coverUrl" :media-kind="request.mediaKind" class="h-[54px] w-9" :icon-size="16" />
|
<RequestCover
|
||||||
|
v-else
|
||||||
|
:src="request.coverUrl"
|
||||||
|
:fallback-sources="requestBookCoverSources(request)"
|
||||||
|
:media-kind="request.mediaKind"
|
||||||
|
class="h-[54px] w-9"
|
||||||
|
:icon-size="16"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1 space-y-1.5">
|
<div class="min-w-0 flex-1 space-y-1.5">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { BookRequestItem, CoverMedium } from '@bookorbit/types'
|
||||||
|
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The library's own cover for a request, tried after the request's art is missing or fails.
|
||||||
|
*
|
||||||
|
* A format asked for from a book's page carries no art of its own, and must not borrow the book's:
|
||||||
|
* filing copies the request's art into the new format's cover slot. The book it joins, or the one
|
||||||
|
* it became, still shows which book this is. The medium is a preference, so a book that has no
|
||||||
|
* slot for it yet answers with the cover it does have.
|
||||||
|
*/
|
||||||
|
export function requestBookCoverSources(request: Pick<BookRequestItem, 'mediaKind' | 'matchedBookId' | 'targetBookId'>): string[] {
|
||||||
|
const bookId = request.matchedBookId ?? request.targetBookId
|
||||||
|
if (bookId === null) return []
|
||||||
|
const medium: CoverMedium = request.mediaKind === 'audiobook' ? 'audio' : 'ebook'
|
||||||
|
return [useCoverVersions().coverUrl(bookId, 'thumbnail', undefined, medium)]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user