mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
98 lines
3.8 KiB
Vue
98 lines
3.8 KiB
Vue
<script setup lang="ts">
|
|
import { computed, inject, ref } from 'vue'
|
|
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'
|
|
|
|
const props = defineProps<{
|
|
book: BookCard
|
|
}>()
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
|
|
|
const collapsed = computed(() => props.book.collapsedSeries!)
|
|
const seriesName = computed(() => props.book.seriesName ?? '')
|
|
const authorLine = computed(() => props.book.authors.join(', ') || null)
|
|
const hoverTitleClampClass = computed(() => (coverAspectRatio.value === '1/1' ? 'line-clamp-1' : 'line-clamp-2'))
|
|
|
|
const coverIds = computed(() => collapsed.value.coverBookIds.filter((bookId) => bookId > 0).slice(0, 4))
|
|
const tileCount = computed(() => Math.max(coverIds.value.length, 1))
|
|
|
|
const failedCovers = ref(new Set<number>())
|
|
|
|
function handleCoverError(bookId: number) {
|
|
failedCovers.value = new Set([...failedCovers.value, bookId])
|
|
}
|
|
|
|
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'
|
|
if (tileCount.value === 3 && index === 0) return 'row-span-2'
|
|
return ''
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="group flex flex-col @container cursor-pointer" @click="handleClick">
|
|
<!-- Cover -->
|
|
<div
|
|
class="relative w-full rounded-sm overflow-hidden shadow-md transition-[box-shadow,transform] duration-150 will-change-transform group-hover:shadow-xl group-hover:scale-[1.02]"
|
|
:style="{ aspectRatio: coverAspectRatio }"
|
|
>
|
|
<!-- Adaptive cover mosaic -->
|
|
<div 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="thumbnailUrl(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>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|