mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(books): show read status on the book detail discovery shelf
The series, author and similar endpoints never selected a read status, so every cover in the detail shelf rendered bare. Attach the requesting user's status in one bulk lookup per lane, and draw the badge in BookCarousel using the same icons and placement as the library grid, plus an sr-only label. BookRepository has no user in scope, so it now returns UnscopedBookRecommendation and the type system forces the status on where the user exists. Also adds the untracked scanner overlap repro for issue 1094. Closes #1163
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { STATUS_COLORS, STATUS_ICONS } from '@/features/book/composables/useBookStatus'
|
||||
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
|
||||
import BookCoverSurface from '@/features/book/components/BookCoverSurface.vue'
|
||||
import type { CoverAspectRatio } from '@bookorbit/types'
|
||||
import type { CoverAspectRatio, ReadStatus, UserBookStatus } from '@bookorbit/types'
|
||||
|
||||
export interface CarouselBook {
|
||||
id: number
|
||||
@@ -16,6 +18,7 @@ export interface CarouselBook {
|
||||
seriesIndex?: string | null
|
||||
hasCover: boolean
|
||||
authors: string[]
|
||||
readStatus?: UserBookStatus | null
|
||||
isAudiobook?: boolean
|
||||
isComic?: boolean
|
||||
}
|
||||
@@ -46,6 +49,7 @@ const cardWidthClass = computed(() => {
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { coverUrl } = useCoverVersions()
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
const coverResetVersion = ref(0)
|
||||
@@ -72,6 +76,16 @@ function isComic(book: CarouselBook): boolean {
|
||||
return book.isComic ?? false
|
||||
}
|
||||
|
||||
function readStatus(book: CarouselBook): ReadStatus | null {
|
||||
const status = book.readStatus?.status
|
||||
return status != null && status !== 'unread' ? status : null
|
||||
}
|
||||
|
||||
function readStatusLabel(status: ReadStatus): string {
|
||||
const key = status.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())
|
||||
return t(`book.readStatus.${key}`)
|
||||
}
|
||||
|
||||
function cardAspectRatio(book: CarouselBook): string {
|
||||
return book.coverAspectRatio
|
||||
}
|
||||
@@ -156,6 +170,13 @@ defineExpose({ scroll })
|
||||
:spine="!isAudiobook(book)"
|
||||
:is-comic="isComic(book)"
|
||||
/>
|
||||
<span
|
||||
v-if="readStatus(book)"
|
||||
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"
|
||||
>
|
||||
<component :is="STATUS_ICONS[readStatus(book)!]" :size="12" :class="STATUS_COLORS[readStatus(book)!]" />
|
||||
<span class="sr-only">{{ readStatusLabel(readStatus(book)!) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="showSeriesIndex && book.seriesIndex != null"
|
||||
class="absolute bottom-1.5 left-1.5 bg-black/60 text-white text-[9px] font-semibold leading-none px-1.5 py-1 rounded-full pointer-events-none"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import BookCarousel, { type CarouselBook } from '../BookCarousel.vue'
|
||||
import type { ReadStatus } from '@bookorbit/types'
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
return { ...actual, useRouter: () => ({ push: vi.fn<(...args: unknown[]) => unknown>() }) }
|
||||
})
|
||||
vi.mock('@/features/book/composables/useCoverVersions', () => ({
|
||||
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg' }),
|
||||
}))
|
||||
|
||||
function makeBook(id: number, status: ReadStatus | null): CarouselBook {
|
||||
return {
|
||||
id,
|
||||
title: `Book ${id}`,
|
||||
coverAspectRatio: '2/3',
|
||||
updatedAt: null,
|
||||
hasCover: true,
|
||||
authors: [],
|
||||
readStatus: status ? { status, source: 'manual', startedAt: null, finishedAt: null, updatedAt: '2026-01-01T00:00:00.000Z' } : null,
|
||||
}
|
||||
}
|
||||
|
||||
function mountCarousel(books: CarouselBook[]) {
|
||||
return mount(BookCarousel, { props: { books, loading: false, showHeader: false } })
|
||||
}
|
||||
|
||||
describe('BookCarousel read status badge', () => {
|
||||
it('labels a card with the read status of its book', () => {
|
||||
const wrapper = mountCarousel([makeBook(1, 'reading')])
|
||||
|
||||
expect(wrapper.get('[data-book-id="1"]').text()).toContain('Reading')
|
||||
})
|
||||
|
||||
it('draws no badge for an unread or untracked book', () => {
|
||||
const wrapper = mountCarousel([makeBook(1, 'unread'), makeBook(2, null)])
|
||||
|
||||
expect(wrapper.get('[data-book-id="1"]').find('.sr-only').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-book-id="2"]').find('.sr-only').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('badges only the books that carry a status', () => {
|
||||
const wrapper = mountCarousel([makeBook(1, 'read'), makeBook(2, null), makeBook(3, 'abandoned')])
|
||||
|
||||
expect(wrapper.get('[data-book-id="1"]').text()).toContain('Read')
|
||||
expect(wrapper.get('[data-book-id="2"]').find('.sr-only').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-book-id="3"]').text()).toContain('Abandoned')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user