mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(cover): show each book's cover in the shape of its primary format
Pick the face slot from the primary file's medium instead of the library's ratio, and send that frame per book on cards, detail, notebook labels and recommendations. Grids keep uniform tiles, and the library setting becomes Grid shape.
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { X } from '@lucide/vue'
|
||||
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
||||
import type { BookDetail, CoverMedium } from '@bookorbit/types'
|
||||
import { useCoverVersions } from '../composables/useCoverVersions'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
||||
import { useBookCoverAspectRatio } from '../composables/useBookCoverAspectRatio'
|
||||
import { faceMedium } from '../lib/cover-slots'
|
||||
import CoverMediumSwitch from './CoverMediumSwitch.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
book: Pick<BookDetail, 'id' | 'title' | 'covers' | 'coverVersion'>
|
||||
book: Pick<BookDetail, 'id' | 'title' | 'covers' | 'coverVersion' | 'coverAspectRatio'>
|
||||
/** The slot to open on. Without one the lightbox opens on the face, as the page shows it. */
|
||||
medium?: CoverMedium | null
|
||||
/** Unsaved editor images, shown in place of the saved slot they would replace. */
|
||||
@@ -21,7 +21,7 @@ const emit = defineEmits<{ 'update:open': [open: boolean] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { coverUrl } = useCoverVersions()
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
const coverAspectRatio = useBookCoverAspectRatio(() => props.book)
|
||||
|
||||
function hasImage(medium: CoverMedium): boolean {
|
||||
return Boolean(props.previews?.[medium]) || props.book.covers[medium] !== null
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, ExternalLink, Eye, Folder, FolderPlus, Headphones, Pencil, Star, Trash2, X } from '@lucide/vue'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
@@ -15,7 +15,7 @@ import BookFormatChip from './BookFormatChip.vue'
|
||||
import { bookFormatEntries, fileFormatKey } from '../lib/book-formats'
|
||||
import { displayPublishedDate } from '../lib/published-date'
|
||||
import { FORMAT_TO_GROUP, getPrimaryBookFile } from '@bookorbit/types'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
||||
import { useBookCoverAspectRatio } from '../composables/useBookCoverAspectRatio'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
|
||||
@@ -77,7 +77,7 @@ const providerLinks = computed(() => (detail.value ? createBookProviderLinks(det
|
||||
|
||||
const safeDescription = useSafeHtml(() => detail.value?.description)
|
||||
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
const coverAspectRatio = useBookCoverAspectRatio(() => detail.value)
|
||||
const { bookCoverDisplayMode } = useDisplaySettings()
|
||||
const quickViewCoverAspectRatio = computed(() => {
|
||||
if (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getBookMediaProfile, type BookCard } from '@bookorbit/types'
|
||||
import { getBookMediaProfile, type BookCard, type CoverAspectRatio } from '@bookorbit/types'
|
||||
import { Library } from '@lucide/vue'
|
||||
import BookCoverArtwork from './BookCoverArtwork.vue'
|
||||
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
|
||||
@@ -16,6 +16,8 @@ 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
|
||||
}>()
|
||||
@@ -23,7 +25,7 @@ const props = defineProps<{
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const injectedCoverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
const coverAspectRatio = computed(() => props.book.coverAspectRatio ?? injectedCoverAspectRatio.value)
|
||||
const effectiveCoverAspectRatio = computed(() => props.coverAspectRatio ?? props.book.coverAspectRatio ?? injectedCoverAspectRatio.value)
|
||||
const { coverUrl } = useCoverVersions()
|
||||
const { seriesCardCoverMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, cardOverlays } = useDisplaySettings()
|
||||
|
||||
@@ -31,7 +33,7 @@ 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(() => (coverAspectRatio.value === '1/1' ? 'line-clamp-1' : 'line-clamp-2'))
|
||||
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'))
|
||||
@@ -172,7 +174,7 @@ const stackCoverStyles = computed(() =>
|
||||
right: `${STACK_COVER_STEP_PCT * index}%`,
|
||||
bottom: `${STACK_COVER_STEP_PCT * index}%`,
|
||||
width: `${coverWidth}%`,
|
||||
aspectRatio: coverAspectRatio.value,
|
||||
aspectRatio: effectiveCoverAspectRatio.value,
|
||||
zIndex: 100 - index,
|
||||
boxShadow:
|
||||
index === 0
|
||||
@@ -211,7 +213,7 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
|
||||
interactive
|
||||
:disable-spine="isAudiobook"
|
||||
:is-comic="isComic"
|
||||
:style="{ aspectRatio: coverAspectRatio, ...(isStack ? { backgroundColor: 'transparent', boxShadow: 'none' } : {}) }"
|
||||
: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" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import { COVER_ASPECT_RATIO_KEY } from '../lib/cover-aspect-ratio'
|
||||
import VirtualBookGrid from './VirtualBookGrid.vue'
|
||||
|
||||
const scrollToItemMock = vi.fn<(index: number, options?: { align?: string }) => void>()
|
||||
@@ -38,9 +39,9 @@ vi.mock('./BookCoverCard.vue', () => ({
|
||||
vi.mock('./CollapsedSeriesCard.vue', () => ({
|
||||
default: {
|
||||
name: 'CollapsedSeriesCard',
|
||||
props: ['book', 'showLabel', 'selectionMode'],
|
||||
props: ['book', 'showLabel', 'selectionMode', 'coverAspectRatio'],
|
||||
template:
|
||||
'<div data-testid="collapsed-series-card" :data-selection-mode="String(selectionMode)">{{ book.id }}<span v-if="showLabel" data-testid="series-card-label-slot" /></div>',
|
||||
'<div data-testid="collapsed-series-card" :data-selection-mode="String(selectionMode)" :data-cover-ratio="coverAspectRatio || \'\'">{{ book.id }}<span v-if="showLabel" data-testid="series-card-label-slot" /></div>',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -178,6 +179,24 @@ describe('VirtualBookGrid', () => {
|
||||
expect(updatedBook?.title).toBe('Updated')
|
||||
})
|
||||
|
||||
it("gives every virtualized tile the grid's shape, whatever the book's own", () => {
|
||||
const series = makeBook(3, {
|
||||
coverAspectRatio: '2/3',
|
||||
collapsedSeries: { bookCount: 2, readCount: 0, coverBookIds: [3], seriesLatestAddedAt: null },
|
||||
})
|
||||
const wrapper = mount(VirtualBookGrid, {
|
||||
props: {
|
||||
books: [makeBook(1, { coverAspectRatio: '2/3' }), makeBook(2, { coverAspectRatio: '1/1' }), series],
|
||||
coverSize: 120,
|
||||
gridGap: 12,
|
||||
},
|
||||
global: { provide: { [COVER_ASPECT_RATIO_KEY as symbol]: ref('1/1') } },
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-testid="book-card"]').map((card) => card.attributes('data-cover-ratio'))).toEqual(['1/1', '1/1'])
|
||||
expect(wrapper.get('[data-testid="collapsed-series-card"]').attributes('data-cover-ratio')).toBe('1/1')
|
||||
})
|
||||
|
||||
it.each([false, true])('forwards selection mode to collapsed series cards when virtualized=%s', (virtualized) => {
|
||||
const seriesBook = makeBook(4, {
|
||||
collapsedSeries: {
|
||||
|
||||
@@ -328,15 +328,18 @@ defineExpose({ scrollToIndex })
|
||||
<template #default="{ item }">
|
||||
<div class="book-grid-cell" :class="{ 'book-grid-cell--new': props.newBookIds.has(item.id) }">
|
||||
<BookCoverSkeleton v-if="isBookPlaceholder(item)" />
|
||||
<!-- Every tile takes the grid's shape; art of the other shape follows the cover display setting. -->
|
||||
<CollapsedSeriesCard
|
||||
v-else-if="asBook(item).collapsedSeries"
|
||||
:book="asBook(item)"
|
||||
:cover-aspect-ratio="coverAspectRatio"
|
||||
:show-label="showLabel"
|
||||
:selection-mode="selectionMode"
|
||||
/>
|
||||
<BookCoverCard
|
||||
v-else
|
||||
:book="asBook(item)"
|
||||
:cover-aspect-ratio="coverAspectRatio"
|
||||
:show-label="showLabel"
|
||||
:selection-mode="selectionMode"
|
||||
:selected="isSelected?.(item.id) ?? false"
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('../../composables/useCoverVersions', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
type LightboxBook = Pick<BookDetail, 'id' | 'title' | 'covers' | 'coverVersion'>
|
||||
type LightboxBook = Pick<BookDetail, 'id' | 'title' | 'covers' | 'coverVersion' | 'coverAspectRatio'>
|
||||
|
||||
function slot(updatedAt: string): BookCoverSlot {
|
||||
return { source: 'extracted', updatedAt, width: null, height: null }
|
||||
@@ -71,6 +71,13 @@ describe('BookCoverLightbox', () => {
|
||||
expect(image().getAttribute('src')).toBe('/api/v1/books/3/cover?t=e1&medium=ebook')
|
||||
})
|
||||
|
||||
it("opens on the book's own face rather than the surrounding view's shape", async () => {
|
||||
mountLightbox({ book: { ...bothSlots, coverAspectRatio: '1/1' } }, '2/3')
|
||||
await flushPromises()
|
||||
|
||||
expect(image().getAttribute('src')).toBe('/api/v1/books/3/cover?t=a1&medium=audio')
|
||||
})
|
||||
|
||||
it('moves between the slots with the arrow keys', async () => {
|
||||
mountLightbox()
|
||||
await flushPromises()
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface CarouselBook {
|
||||
title: string | null
|
||||
coverAspectRatio: CoverAspectRatio
|
||||
updatedAt?: string | null
|
||||
coverVersion?: string
|
||||
seriesIndex?: string | null
|
||||
hasCover: boolean
|
||||
authors: string[]
|
||||
@@ -157,7 +158,7 @@ defineExpose({ scroll })
|
||||
:style="{ aspectRatio: cardAspectRatio(book) }"
|
||||
>
|
||||
<BookCoverArtwork
|
||||
:src="coverUrl(book.id, 'thumbnail', book.updatedAt)"
|
||||
:src="coverUrl(book.id, 'thumbnail', book.coverVersion ?? book.updatedAt)"
|
||||
:has-cover="book.hasCover"
|
||||
:title="book.title"
|
||||
:author-line="book.authors.length > 0 ? book.authors.join(', ') : null"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, onUnmounted, reactive, ref, useId, watch } from 'vue'
|
||||
import { computed, nextTick, onUnmounted, reactive, ref, useId, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Image, ImagePlus, Link, Loader2, Lock, LockOpen, Maximize2, RotateCcw, Search, Upload } from '@lucide/vue'
|
||||
import type { BookDetail, BookMetadataLockField, CoverMedium } from '@bookorbit/types'
|
||||
@@ -7,7 +7,7 @@ import { hideOnError } from '../../../lib/metadata-fetch'
|
||||
import { useCoverEditor } from '../../../composables/useCoverEditor'
|
||||
import { useCoverVersions } from '../../../composables/useCoverVersions'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
|
||||
import { useBookCoverAspectRatio } from '../../../composables/useBookCoverAspectRatio'
|
||||
import { coverFieldMedium, coverLockField, coverTileState, editorTiles } from '../../../lib/cover-slots'
|
||||
import { nextRovingIndex } from '@/lib/roving-index'
|
||||
import BookCoverLightbox from '@/features/book/components/BookCoverLightbox.vue'
|
||||
@@ -23,7 +23,7 @@ const emit = defineEmits<{ coverChanged: [medium: CoverMedium | null]; toggleLoc
|
||||
const { t } = useI18n()
|
||||
const { coverUrl } = useCoverVersions()
|
||||
const { hasPermission } = usePermissions()
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
const coverAspectRatio = useBookCoverAspectRatio(() => props.book)
|
||||
|
||||
const bookIdRef = computed(() => props.book.id)
|
||||
// One editor per slot, so a pending image on one tile survives selecting the other.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDate as formatLocaleDate } from '@/i18n/formatters'
|
||||
import { formatBytes as formatFileSize } from '@/lib/formatting'
|
||||
@@ -31,7 +31,7 @@ import { faceMedium } from '@/features/book/lib/cover-slots'
|
||||
import { readingDateToDateKey } from '@/features/book/lib/reading-date'
|
||||
import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
|
||||
import { useBookCoverAspectRatio } from '@/features/book/composables/useBookCoverAspectRatio'
|
||||
import { FORMAT_TO_GROUP, getPrimaryBookFile, READER_OPENABLE_FORMATS } from '@bookorbit/types'
|
||||
import type { BookDetail, BookKoboState, CustomMetadataBookValue, ReadAloudProgressSync, ReadStatus, UserBookStatus } from '@bookorbit/types'
|
||||
import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/features/book/composables/useBookStatus'
|
||||
@@ -341,7 +341,7 @@ watch([coverColumnEl, coverActionsEl], ([column, actions]) => {
|
||||
measureCoverLayout()
|
||||
})
|
||||
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
const coverAspectRatio = useBookCoverAspectRatio(() => props.book)
|
||||
const { bookCoverDisplayMode } = useDisplaySettings()
|
||||
const detailCoverAspectRatio = computed(() => {
|
||||
if (bookCoverDisplayMode.value !== 'natural-bottom' || !hasCover.value || !coverLoaded.value || coverFailed.value || !coverImageRatio.value) {
|
||||
@@ -354,7 +354,7 @@ const primaryFile = computed(() => getPrimaryBookFile(props.book.files))
|
||||
const readAlongFile = computed(() => props.book.files.find((file) => hasReadAlong(file)) ?? null)
|
||||
const hasAudioFile = computed(() => props.book.files.some((file) => file.format != null && FORMAT_TO_GROUP[file.format] === 'audio'))
|
||||
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
|
||||
// The hero shows the face, whose shape follows the library rather than the primary file once slots exist.
|
||||
// The hero shows the face: the primary file's slot, or the other one when that slot has no art.
|
||||
const isFaceAudio = computed(() => {
|
||||
if (!props.book.covers.ebook && !props.book.covers.audio) return isPrimaryAudio.value
|
||||
return faceMedium(props.book, coverAspectRatio.value) === 'audio'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ArrowLeft, Loader2, Pencil, X } from '@lucide/vue'
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
||||
import type { BookCard } from '@bookorbit/types'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../lib/cover-aspect-ratio'
|
||||
import { useBookCoverAspectRatio } from '../../composables/useBookCoverAspectRatio'
|
||||
import { useBookDetail } from '../../composables/useBookDetail'
|
||||
import { useMetadataLocks } from '../../composables/useMetadataLocks'
|
||||
import { useCoverVersions } from '../../composables/useCoverVersions'
|
||||
@@ -31,10 +31,10 @@ const mode = ref<'view' | 'edit'>('view')
|
||||
const { detail, loading, fetch } = useBookDetail()
|
||||
const { lockedFields, toggle, load: loadLocks } = useMetadataLocks()
|
||||
const { coverUrl } = useCoverVersions()
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
const editor = ref<InstanceType<typeof CoverEditorPanel> | null>(null)
|
||||
|
||||
const currentDetail = computed(() => (detail.value && detail.value.id === props.book?.id ? detail.value : null))
|
||||
const coverAspectRatio = useBookCoverAspectRatio(() => currentDetail.value ?? props.book)
|
||||
const hasCoverResolved = computed(() => (currentDetail.value ? currentDetail.value.coverSource !== null : (props.book?.hasCover ?? false)))
|
||||
const coverVersion = computed(() => currentDetail.value?.coverVersion ?? props.book?.coverVersion ?? null)
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { computed, inject, ref, type ComputedRef } from 'vue'
|
||||
import type { CoverAspectRatio } from '@bookorbit/types'
|
||||
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
||||
|
||||
/**
|
||||
* The frame of a book's face. The server sends it per book, following the primary file's medium;
|
||||
* the surrounding view's shape (the library's) serves only until the book has loaded.
|
||||
*/
|
||||
export function useBookCoverAspectRatio(book: () => { coverAspectRatio?: CoverAspectRatio } | null | undefined): ComputedRef<CoverAspectRatio> {
|
||||
const injected = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
return computed(() => book()?.coverAspectRatio ?? injected.value)
|
||||
}
|
||||
@@ -119,6 +119,13 @@ describe('mergeBookCardWithDetail', () => {
|
||||
expect(result.metadataScore).toBe(95)
|
||||
})
|
||||
|
||||
it("takes the detail's cover frame, keeping the card's when the detail has none", () => {
|
||||
const book = makeBook({ coverAspectRatio: '2/3' })
|
||||
|
||||
expect(mergeBookCardWithDetail(book, makeDetail({ coverAspectRatio: '1/1' })).coverAspectRatio).toBe('1/1')
|
||||
expect(mergeBookCardWithDetail(book, makeDetail()).coverAspectRatio).toBe('2/3')
|
||||
})
|
||||
|
||||
it('maps authors from detail.authors[].name array', () => {
|
||||
const book = makeBook()
|
||||
const detail = makeDetail({
|
||||
|
||||
@@ -35,6 +35,7 @@ export function mergeBookCardWithDetail(book: BookCard, detail: BookDetail): Boo
|
||||
readStatus: detail.readStatus,
|
||||
metadataScore: detail.metadataScore,
|
||||
hasCover: detail.coverSource !== null,
|
||||
coverAspectRatio: detail.coverAspectRatio ?? book.coverAspectRatio,
|
||||
coverVersion: detail.coverVersion,
|
||||
hasMetadataLocks: detail.lockedFields.length > 0,
|
||||
lockedFields: detail.lockedFields,
|
||||
|
||||
@@ -26,7 +26,10 @@ export function coverLockField(medium: CoverMedium | null): Extract<BookMetadata
|
||||
return medium === 'audio' ? 'audioCover' : 'cover'
|
||||
}
|
||||
|
||||
/** Mirrors the server's face rule: a square library shows the audio slot, any other shape the ebook slot. */
|
||||
/**
|
||||
* The slot a frame of this shape shows. Given the book's own `coverAspectRatio`, which the server
|
||||
* derives from the slot it serves, this is the server's face.
|
||||
*/
|
||||
export function faceMedium(book: Pick<SlotBook, 'covers'>, aspectRatio: CoverAspectRatio | string): CoverMedium {
|
||||
const preferred: CoverMedium = aspectRatio === '1/1' ? 'audio' : 'ebook'
|
||||
if (book.covers[preferred]) return preferred
|
||||
|
||||
@@ -58,14 +58,14 @@ const iconChoices = computed(() => (props.icon && !SUGGESTED_ICONS.includes(prop
|
||||
const coverOptions = computed<{ value: CoverAspectRatio; title: string; hint: string; tiles: string[] }[]>(() => [
|
||||
{
|
||||
value: '2/3',
|
||||
title: t('library.creator.details.cover.portraitTitle'),
|
||||
hint: t('library.creator.details.cover.portraitHint'),
|
||||
title: t('library.creator.details.gridShape.tallTitle'),
|
||||
hint: t('library.creator.details.gridShape.tallHint'),
|
||||
tiles: ['var(--format-ebook)', 'var(--format-document)', 'var(--format-audio)'],
|
||||
},
|
||||
{
|
||||
value: '1/1',
|
||||
title: t('library.creator.details.cover.squareTitle'),
|
||||
hint: t('library.creator.details.cover.squareHint'),
|
||||
title: t('library.creator.details.gridShape.squareTitle'),
|
||||
hint: t('library.creator.details.gridShape.squareHint'),
|
||||
tiles: ['var(--format-kindle)', 'var(--format-comic)', 'var(--format-audio)'],
|
||||
},
|
||||
])
|
||||
@@ -193,9 +193,9 @@ function selectPodcastsType() {
|
||||
</div>
|
||||
</LibraryCreatorCard>
|
||||
|
||||
<LibraryCreatorCard v-if="type === 'books'" :label="t('library.creator.details.coverStyle.title')">
|
||||
<LibraryCreatorCard v-if="type === 'books'" :label="t('library.creator.details.gridShape.title')">
|
||||
<fieldset aria-describedby="cover-style-description">
|
||||
<legend class="sr-only">{{ t('library.creator.details.coverStyle.title') }}</legend>
|
||||
<legend class="sr-only">{{ t('library.creator.details.gridShape.title') }}</legend>
|
||||
<div class="grid gap-2.5 @md:grid-cols-2">
|
||||
<label
|
||||
v-for="option in coverOptions"
|
||||
@@ -231,7 +231,7 @@ function selectPodcastsType() {
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p id="cover-style-description" class="mt-2.5 text-xs text-muted-foreground">{{ t('library.creator.details.cover.slotsHint') }}</p>
|
||||
<p id="cover-style-description" class="mt-2.5 text-xs text-muted-foreground">{{ t('library.creator.details.gridShape.hint') }}</p>
|
||||
</fieldset>
|
||||
</LibraryCreatorCard>
|
||||
</div>
|
||||
|
||||
@@ -214,9 +214,9 @@ async function copyPath(folderId: number, path: string) {
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 border-t border-border py-1.5">
|
||||
<dt class="shrink-0 text-[12.5px] text-muted-foreground">{{ t('settings.admin.libraries.detail.coverShape') }}</dt>
|
||||
<dt class="shrink-0 text-[12.5px] text-muted-foreground">{{ t('settings.admin.libraries.detail.gridShape') }}</dt>
|
||||
<dd class="ms-auto text-[12.5px] font-medium text-foreground">
|
||||
{{ t(`settings.admin.libraries.detail.coverShapeValue.${library.coverAspectRatio === '1/1' ? 'square' : 'portrait'}`) }}
|
||||
{{ t(`settings.admin.libraries.detail.gridShapeValue.${library.coverAspectRatio === '1/1' ? 'square' : 'tall'}`) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -1216,6 +1216,11 @@
|
||||
"portrait": "2:3 portrait",
|
||||
"square": "1:1 square"
|
||||
},
|
||||
"gridShape": "Grid shape",
|
||||
"gridShapeValue": {
|
||||
"tall": "Tall cells",
|
||||
"square": "Square cells"
|
||||
},
|
||||
"none": "None",
|
||||
"allSupported": "All supported",
|
||||
"patternCount": "{count, plural, one {# pattern} other {# patterns}}",
|
||||
@@ -8817,6 +8822,14 @@
|
||||
"squareTitle": "Square 1:1",
|
||||
"squareHint": "Audiobook-only libraries",
|
||||
"slotsHint": "A book with both an ebook and an audiobook shows the cover that fits this shape."
|
||||
},
|
||||
"gridShape": {
|
||||
"title": "Grid shape",
|
||||
"tallTitle": "Tall cells",
|
||||
"tallHint": "Best for ebooks or mixed libraries",
|
||||
"squareTitle": "Square cells",
|
||||
"squareHint": "Best for audiobook libraries",
|
||||
"hint": "Each book shows the cover of its primary format, square for an audiobook. A cover that doesn't match the tile follows your cover display setting."
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
|
||||
@@ -35,6 +35,7 @@ const { libraries } = useLibraries()
|
||||
provide(
|
||||
COVER_ASPECT_RATIO_KEY,
|
||||
computed(() => {
|
||||
if (detail.value?.coverAspectRatio) return detail.value.coverAspectRatio
|
||||
const libraryId = detail.value?.libraryId
|
||||
const library = libraryId != null ? libraries.value.find((l) => l.id === libraryId) : null
|
||||
return library?.coverAspectRatio ?? DEFAULT_COVER_ASPECT_RATIO
|
||||
|
||||
Reference in New Issue
Block a user