mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
af8f55976f
Add a Style setting beside mode, accent, radius and background. Nostalgia restyles the app through tokens only (neutral surface palettes, system type, flat shell, top glow) and pins background, opacity and brightness. Add a top glow reach slider for both styles. Tidy the theme layer first: charts read live tokens, bare `rounded` follows the radius scale, one shared sign-in toolbar. Also carries the settings page header redesign.
433 lines
16 KiB
Vue
433 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { computed, inject, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { getBookMediaProfile, type BookCard, type CoverAspectRatio } from '@bookorbit/types'
|
|
import { Library } from '@lucide/vue'
|
|
import BookCoverArtwork from './BookCoverArtwork.vue'
|
|
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
|
|
import BookCoverSurface from './BookCoverSurface.vue'
|
|
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
|
import { useCoverVersions } from '../composables/useCoverVersions'
|
|
import { useDisplaySettings, type GridCardLabelField } from '@/composables/useDisplaySettings'
|
|
import { fetchAuthors } from '@/features/author/api/author'
|
|
|
|
const MAX_COLLAPSED_STACK_COVERS = 3
|
|
const STACK_COVER_STEP_PCT = 8
|
|
|
|
const props = defineProps<{
|
|
book: BookCard
|
|
/** A grid's own tile shape, which wins over the book's. */
|
|
coverAspectRatio?: CoverAspectRatio
|
|
showLabel?: boolean
|
|
selectionMode?: boolean
|
|
}>()
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const injectedCoverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
|
const effectiveCoverAspectRatio = computed(() => props.coverAspectRatio ?? props.book.coverAspectRatio ?? injectedCoverAspectRatio.value)
|
|
const { coverUrl } = useCoverVersions()
|
|
const { seriesCardCoverMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, cardOverlays } = useDisplaySettings()
|
|
|
|
const collapsed = computed(() => props.book.collapsedSeries!)
|
|
const seriesName = computed(() => props.book.seriesName ?? '')
|
|
const authorLine = computed(() => props.book.authors.join(', ') || null)
|
|
const authorQuery = computed(() => props.book.authors[0] ?? null)
|
|
const hoverTitleClampClass = computed(() => (effectiveCoverAspectRatio.value === '1/1' ? 'line-clamp-1' : 'line-clamp-2'))
|
|
const showHoverText = computed(() => cardInfoMode.value === 'hover-overlay')
|
|
const overlayBackground = computed(() => (cardInfoMode.value === 'hover-overlay' ? 'bg-black/70' : 'bg-black/20'))
|
|
const showProgressBar = computed(() => cardOverlays.value.includes('progress-bar'))
|
|
const progressPercent = computed(() => {
|
|
const { bookCount, readCount } = collapsed.value
|
|
if (bookCount <= 0) return 0
|
|
return Math.min(100, Math.max(0, (readCount / bookCount) * 100))
|
|
})
|
|
|
|
const isMosaic = computed(() => seriesCardCoverMode.value === 'mosaic')
|
|
const isStack = computed(() => seriesCardCoverMode.value === 'stack')
|
|
|
|
const allCoverIds = computed(() => collapsed.value.coverBookIds.filter((bookId) => bookId > 0))
|
|
const mosaicCoverIds = computed(() => allCoverIds.value.slice(0, 4))
|
|
const tileCount = computed(() => Math.max(mosaicCoverIds.value.length, 1))
|
|
const failedCovers = ref(new Set<number>())
|
|
const loadedCovers = ref(new Set<number>())
|
|
const activeStackCoverIds = computed(() => allCoverIds.value.filter((bookId) => !failedCovers.value.has(bookId)))
|
|
const stackCoverIds = computed(() => activeStackCoverIds.value.slice(0, MAX_COLLAPSED_STACK_COVERS))
|
|
|
|
const resolvedCoverId = computed<number | null>(() => {
|
|
const c = collapsed.value
|
|
const fallback = mosaicCoverIds.value[0] ?? null
|
|
switch (seriesCardCoverMode.value) {
|
|
case 'first-volume':
|
|
return c.firstVolumeBookId ?? fallback
|
|
case 'latest-volume':
|
|
return c.latestVolumeBookId ?? fallback
|
|
case 'first-unread':
|
|
return c.firstUnreadBookId ?? c.firstVolumeBookId ?? fallback
|
|
default:
|
|
return null
|
|
}
|
|
})
|
|
|
|
const singleCoverFailed = ref(false)
|
|
const singleCoverLoaded = ref(false)
|
|
|
|
watch(resolvedCoverId, () => {
|
|
singleCoverFailed.value = false
|
|
singleCoverLoaded.value = false
|
|
})
|
|
watch(
|
|
allCoverIds,
|
|
(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 mediaProfile = computed(() => getBookMediaProfile(props.book.files))
|
|
const isAudiobook = computed(() => mediaProfile.value.primaryMediaKind === 'audiobook')
|
|
const isComic = computed(() => mediaProfile.value.primaryMediaKind === 'comic')
|
|
|
|
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
|
|
}
|
|
|
|
function coverVersionFor(bookId: number): string | null | undefined {
|
|
if (bookId === props.book.id) return props.book.coverVersion
|
|
return collapsed.value.coverUpdatedAtByBookId?.[bookId]
|
|
}
|
|
|
|
function handleClick() {
|
|
openSeriesDetails()
|
|
}
|
|
|
|
function openSeriesDetails() {
|
|
if (props.selectionMode) return
|
|
if (props.book.seriesId == null) return
|
|
void router.push({ name: 'series-detail', params: { seriesId: props.book.seriesId }, query: { from: route.fullPath } })
|
|
}
|
|
|
|
async function openAuthorDetails() {
|
|
if (props.selectionMode) return
|
|
const authorName = authorQuery.value?.trim()
|
|
if (!authorName) return
|
|
|
|
try {
|
|
const page = await fetchAuthors({ q: authorName, page: 0, size: 5, sort: 'name', order: 'asc' })
|
|
const author = page.items.find((item) => item.name.trim().toLocaleLowerCase() === authorName.toLocaleLowerCase())
|
|
if (author) {
|
|
void router.push({ name: 'author-detail', params: { id: author.id }, query: { from: route.fullPath } })
|
|
return
|
|
}
|
|
} catch {
|
|
// Fall back to the filtered author list below.
|
|
}
|
|
|
|
void router.push({ name: 'authors', query: { q: authorName } })
|
|
}
|
|
|
|
function handleLabelClick(field: GridCardLabelField) {
|
|
if (props.selectionMode) return
|
|
if (field === 'author') {
|
|
void openAuthorDetails()
|
|
return
|
|
}
|
|
openSeriesDetails()
|
|
}
|
|
|
|
function handlePrimaryLabelClick() {
|
|
handleLabelClick(gridCardPrimaryLabel.value)
|
|
}
|
|
|
|
function handleSecondaryLabelClick() {
|
|
handleLabelClick(gridCardSecondaryLabel.value)
|
|
}
|
|
|
|
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 ''
|
|
}
|
|
|
|
const stackCoverStyles = computed(() =>
|
|
stackCoverIds.value.map((_, index) => {
|
|
const coverWidth = 100 - STACK_COVER_STEP_PCT * Math.max(0, stackCoverIds.value.length - 1)
|
|
|
|
return {
|
|
right: `${STACK_COVER_STEP_PCT * index}%`,
|
|
bottom: `${STACK_COVER_STEP_PCT * index}%`,
|
|
width: `${coverWidth}%`,
|
|
aspectRatio: effectiveCoverAspectRatio.value,
|
|
zIndex: 100 - index,
|
|
boxShadow:
|
|
index === 0
|
|
? '0 18px 34px -20px rgba(15, 23, 42, 0.72), 0 8px 14px -12px rgba(15, 23, 42, 0.28)'
|
|
: '0 14px 26px -20px rgba(15, 23, 42, 0.58), 0 6px 12px -12px rgba(15, 23, 42, 0.22)',
|
|
transformOrigin: 'center',
|
|
transform: 'translateY(0) scale(1)',
|
|
transition: 'transform 190ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms ease',
|
|
willChange: 'transform',
|
|
}
|
|
}),
|
|
)
|
|
|
|
function resolveSeriesLabel(field: GridCardLabelField): string | null {
|
|
if (field === 'hidden') return null
|
|
if (field === 'author') return authorLine.value?.trim() || null
|
|
return seriesName.value?.trim() || null
|
|
}
|
|
|
|
const primaryLabelText = computed(() => resolveSeriesLabel(gridCardPrimaryLabel.value))
|
|
const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLabel.value))
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="flex flex-col @container"
|
|
:class="selectionMode ? 'cursor-default' : 'cursor-pointer'"
|
|
:inert="selectionMode || undefined"
|
|
@click="handleClick"
|
|
>
|
|
<!-- Cover -->
|
|
<div class="group">
|
|
<BookCoverSurface
|
|
class="relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform] duration-150 will-change-transform group-hover:[transform:var(--cover-hover-transform)] motion-reduce:group-hover:[transform:none]"
|
|
:class="isStack ? 'book-cover-surface--spine-fitted' : ''"
|
|
interactive
|
|
:disable-spine="isAudiobook"
|
|
:is-comic="isComic"
|
|
:style="{ aspectRatio: effectiveCoverAspectRatio, ...(isStack ? { backgroundColor: 'transparent', boxShadow: 'none' } : {}) }"
|
|
>
|
|
<div v-if="isStack" class="absolute inset-0 isolate overflow-hidden" data-testid="series-cover-stack">
|
|
<div class="absolute inset-x-[12%] bottom-[5%] h-5 rounded-full bg-black/12 blur-2xl opacity-45" />
|
|
<div
|
|
v-for="(bookId, i) in stackCoverIds"
|
|
:key="bookId"
|
|
class="absolute overflow-hidden rounded-md"
|
|
:style="stackCoverStyles[i] ?? {}"
|
|
data-testid="series-stack-cover"
|
|
>
|
|
<BookCoverArtwork
|
|
:src="coverUrl(bookId, 'thumbnail', coverVersionFor(bookId))"
|
|
:has-cover="true"
|
|
:title="seriesName"
|
|
:author-line="authorLine"
|
|
:seed="`series-${bookId}`"
|
|
alt=""
|
|
loading="lazy"
|
|
decoding="async"
|
|
:spine="!isAudiobook"
|
|
:is-comic="isComic"
|
|
@error="() => handleCoverError(bookId)"
|
|
/>
|
|
|
|
<template v-if="i === 0">
|
|
<div
|
|
class="absolute right-1.5 top-1.5 z-20 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>
|
|
</template>
|
|
</div>
|
|
<div
|
|
v-if="stackCoverIds.length === 0"
|
|
class="absolute inset-x-[24%] bottom-[6%] top-[6%] overflow-hidden rounded-md shadow-[0_12px_28px_-18px_rgba(15,23,42,0.7)]"
|
|
data-testid="series-cover-stack-fallback"
|
|
>
|
|
<BookCoverPlaceholder title="" author-line="" :is-audio="false" seed="series-empty" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Single cover mode -->
|
|
<div v-else-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, 'thumbnail', coverVersionFor(resolvedCoverId))"
|
|
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"
|
|
/>
|
|
<span v-if="!singleCoverLoaded && !singleCoverFailed" class="absolute inset-0 z-[1] animate-pulse bg-foreground/5" />
|
|
</div>
|
|
|
|
<!-- Adaptive cover mosaic -->
|
|
<div v-else class="absolute inset-0 grid grid-cols-2 grid-rows-2">
|
|
<template v-for="(bookId, i) in mosaicCoverIds" :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, 'thumbnail', coverVersionFor(bookId))"
|
|
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)"
|
|
/>
|
|
<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="mosaicCoverIds.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
|
|
v-if="!isStack"
|
|
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>
|
|
|
|
<!-- Series type badge -->
|
|
<div
|
|
v-if="!isStack"
|
|
class="absolute bottom-1.5 left-1.5 z-10 rounded-sm bg-black/70 p-1 text-white group-hover:opacity-0 transition-opacity duration-150"
|
|
data-testid="series-type-badge"
|
|
>
|
|
<Library :size="10" />
|
|
</div>
|
|
|
|
<!-- Hover overlay -->
|
|
<div
|
|
v-if="!isStack"
|
|
class="absolute inset-0 flex flex-col p-2 opacity-0 group-hover:opacity-100 group-active:opacity-100 transition-opacity duration-150 pointer-events-none group-hover:pointer-events-auto group-active:pointer-events-auto"
|
|
:class="overlayBackground"
|
|
>
|
|
<div class="flex-1 flex items-center justify-center">
|
|
<button
|
|
class="size-[30cqi] flex items-center justify-center rounded-full bg-primary text-white shadow-2xl transition-all duration-300 scale-75 group-hover:scale-100"
|
|
data-testid="series-hover-action"
|
|
@click.stop="handleClick"
|
|
>
|
|
<Library class="size-[16cqi]" />
|
|
</button>
|
|
</div>
|
|
<div v-if="showHoverText" 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>
|
|
|
|
<!-- Read progress bar -->
|
|
<div v-if="!isStack && showProgressBar" class="absolute bottom-0 left-0 right-0 h-[3px] z-20 bg-white/20" data-testid="series-progress-bar">
|
|
<div class="h-full bg-primary" :style="{ width: `${progressPercent}%` }" data-testid="series-progress-fill" />
|
|
</div>
|
|
</BookCoverSurface>
|
|
</div>
|
|
|
|
<div v-if="props.showLabel && cardInfoMode === 'below-cover' && (primaryLabelText || secondaryLabelText)" class="grid-card-label">
|
|
<button
|
|
v-if="primaryLabelText"
|
|
type="button"
|
|
class="grid-card-label__primary"
|
|
data-testid="grid-card-label-primary"
|
|
@click.stop="handlePrimaryLabelClick"
|
|
>
|
|
{{ primaryLabelText }}
|
|
</button>
|
|
<button
|
|
v-if="secondaryLabelText"
|
|
type="button"
|
|
class="grid-card-label__secondary"
|
|
data-testid="grid-card-label-secondary"
|
|
@click.stop="handleSecondaryLabelClick"
|
|
>
|
|
{{ secondaryLabelText }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.grid-card-label {
|
|
padding-top: 4px;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.grid-card-label__primary {
|
|
font-size: 0.75rem;
|
|
line-height: 1.25rem;
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--foreground);
|
|
text-align: left;
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
min-width: 0;
|
|
width: 100%;
|
|
}
|
|
|
|
.grid-card-label__primary:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.grid-card-label__secondary {
|
|
font-size: 0.675rem;
|
|
line-height: 0.875rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--muted-foreground);
|
|
margin-top: 2px;
|
|
text-align: left;
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
min-width: 0;
|
|
width: 100%;
|
|
}
|
|
|
|
.grid-card-label__secondary:hover {
|
|
text-decoration: underline;
|
|
}
|
|
</style>
|