feat(cover): add per-library aspect ratio and per-view cover size

This commit is contained in:
neon
2026-03-26 21:14:22 -06:00
parent 61746cb62c
commit 10a4a2045a
28 changed files with 303 additions and 106 deletions
@@ -0,0 +1,44 @@
import { ref, watch, type Ref } from 'vue'
import { storage } from '@/services/storage'
type ViewType = 'library' | 'lens' | 'collection'
function coverSizeKey(type: ViewType, id: number) {
return `projectx:coverSize:${type}:${id}`
}
function gridGapKey(type: ViewType, id: number) {
return `projectx:gridGap:${type}:${id}`
}
export function useViewDisplaySettings(viewType: ViewType, viewId: Readonly<Ref<number | null>>) {
const globalCoverSize = storage.get('coverSize', 140)
const globalGridGap = storage.get('gridGap', 20)
const coverSize = ref(globalCoverSize)
const gridGap = ref(globalGridGap)
function loadForView(id: number | null) {
if (id === null || !Number.isFinite(id)) {
coverSize.value = globalCoverSize
gridGap.value = globalGridGap
return
}
coverSize.value = storage.get(coverSizeKey(viewType, id), globalCoverSize)
gridGap.value = storage.get(gridGapKey(viewType, id), globalGridGap)
}
watch(viewId, loadForView, { immediate: true })
watch(coverSize, (v) => {
const id = viewId.value
if (id !== null && Number.isFinite(id)) storage.set(coverSizeKey(viewType, id), v)
})
watch(gridGap, (v) => {
const id = viewId.value
if (id !== null && Number.isFinite(id)) storage.set(gridGapKey(viewType, id), v)
})
return { coverSize, gridGap }
}
@@ -3,7 +3,7 @@ import type { BookCard, BookFileRef } from '@projectx/types'
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@projectx/types'
import { bookCoverStyle } from '../lib/book-cover'
import { getFormatColor } from '../lib/format-colors'
import { computed, ref, watch } from 'vue'
import { computed, inject, ref, watch, type ComponentPublicInstance } from 'vue'
import { useRouter } from 'vue-router'
import {
BookOpen,
@@ -40,6 +40,7 @@ import { useRefreshMetadata } from '../composables/useRefreshMetadata'
import { useRefreshingBooks } from '../composables/useRefreshingBooks'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { useBookDownload } from '@/features/book/composables/useBookDownload'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
@@ -104,6 +105,7 @@ async function reExtractCover() {
}
const { hasPermission } = usePermissions()
const { cardOverlays } = useDisplaySettings()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const showSendDialog = ref(false)
const showSeriesOverlay = computed(() => cardOverlays.value.includes('series') && seriesLine.value != null)
@@ -136,6 +138,11 @@ watch(coverSrc, () => {
coverFailed.value = false
})
function onMainImgRef(el: Element | ComponentPublicInstance | null) {
const img = el as HTMLImageElement | null
if (img?.complete && img.naturalWidth > 0) coverLoaded.value = true
}
function openFile(file: BookFileRef) {
router.push({
name: 'reader',
@@ -153,6 +160,10 @@ function handleCardClick(event: MouseEvent) {
if (primaryFile.value && !isMissing.value) openFile(primaryFile.value)
}
function openQuickView() {
emit('action', 'quick-view')
}
function openAuthorBrowse() {
if (!authorQuery.value) return
void router.push({ name: 'authors', query: { q: authorQuery.value } })
@@ -202,16 +213,26 @@ async function handleSetStatus(status: ReadStatus) {
<div
class="relative w-full rounded-sm overflow-hidden shadow-md transition-[box-shadow,transform,ring] duration-150 will-change-transform"
:class="[isMissing ? '' : selectionMode ? '' : 'group-hover:shadow-xl group-hover:scale-[1.02]']"
style="aspect-ratio: 2/3"
:style="coverLoaded ? {} : coverStyle"
:style="[{ aspectRatio: coverAspectRatio }, !coverLoaded || coverFailed ? coverStyle : {}]"
>
<!-- Missing border overlay: mirror selected overlay pattern so border is never clipped/hidden -->
<div v-if="isMissing" class="absolute inset-0 z-30 pointer-events-none rounded-sm ring-2 ring-inset ring-amber-500" />
<!-- Blurred background fill for mismatched aspect ratios -->
<img
v-if="!coverFailed"
:src="coverSrc"
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-200"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-90 transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
aria-hidden="true"
loading="lazy"
/>
<img
v-if="!coverFailed"
:ref="onMainImgRef"
:src="coverSrc"
class="absolute inset-0 w-full h-full object-contain transition-opacity duration-200"
:class="coverLoaded ? (isMissing ? 'opacity-100 brightness-50' : 'opacity-100') : 'opacity-0'"
loading="lazy"
decoding="async"
@@ -347,28 +368,27 @@ async function handleSetStatus(status: ReadStatus) {
<!-- Hover overlay -->
<div
v-if="!selectionMode"
class="absolute inset-0 flex flex-col p-2 bg-black/80 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
@click.stop
class="absolute inset-0 flex flex-col p-2 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
@click.stop="handleCardClick"
>
<!-- Center: primary action buttons -->
<div class="flex-1 flex flex-col items-center justify-center gap-[18cqi]">
<button
v-if="primaryFile && !isMissing"
class="p-[7cqi] rounded-full bg-primary/50 hover:bg-primary transition-colors text-white"
@click="openFile(primaryFile)"
>
<BookOpen class="size-[14cqi]" />
</button>
<button class="p-[7cqi] rounded-full bg-primary/70 hover:bg-primary transition-colors text-white" @click="emit('action', 'quick-view')">
<PanelRight class="size-[14cqi]" />
<!-- Top row: Quick View -->
<div class="shrink-0 flex justify-end">
<button class="p-1 rounded bg-primary/70 hover:bg-primary transition-colors text-white" @click="openQuickView">
<PanelRight class="size-4" />
</button>
</div>
<div class="flex-1 min-h-0" />
<!-- Bottom: title/author + kebab -->
<div class="flex items-end justify-between gap-2">
<div class="shrink-0 flex items-center justify-between gap-2">
<div v-if="coverLoaded" class="min-w-0 flex-1">
<p class="text-xs font-semibold text-white leading-tight line-clamp-2">{{ book.title ?? '-' }}</p>
<button v-if="authorLine" class="text-[10px] text-white/70 truncate mt-0.5 hover:underline" @click.stop="openAuthorBrowse">
<button
v-if="authorLine && coverAspectRatio !== '1/1'"
class="text-[10px] text-white/70 truncate mt-0.5 hover:underline"
@click.stop="openAuthorBrowse"
>
{{ authorLine }}
</button>
</div>
@@ -2,7 +2,7 @@
import type { BookCard, BookFileRef } from '@projectx/types'
import { bookCoverStyle } from '../lib/book-cover'
import { api } from '@/lib/api'
import { computed, ref, watch } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
BookOpen,
@@ -22,6 +22,7 @@ import {
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCoverVersions } from '../composables/useCoverVersions'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { useRefreshMetadata } from '../composables/useRefreshMetadata'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
@@ -93,6 +94,7 @@ watch(coverSrc, () => {
})
const { refreshing, refreshWithFeedback } = useRefreshMetadata()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
function openFile(file: BookFileRef) {
router.push({
@@ -129,14 +131,23 @@ function openAuthorBrowse() {
<!-- Cover -->
<div
class="h-24 w-16 rounded shrink-0 overflow-hidden relative"
class="w-16 rounded shrink-0 overflow-hidden relative"
:class="isMissing ? 'opacity-50 grayscale' : ''"
:style="coverLoaded ? {} : coverStyle"
:style="[{ aspectRatio: coverAspectRatio }, !coverLoaded || coverFailed ? coverStyle : {}]"
>
<!-- Blurred background fill for mismatched aspect ratios -->
<img
v-if="!coverFailed"
:src="coverSrc"
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-200"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-50 transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
aria-hidden="true"
loading="lazy"
/>
<img
v-if="!coverFailed"
:src="coverSrc"
class="absolute inset-0 w-full h-full object-contain transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
loading="lazy"
:alt="book.title ?? ''"
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, ExternalLink, FolderPlus, Pencil, Star, Trash2, X } from 'lucide-vue-next'
import { BookOpen, ExternalLink, FolderPlus, Headphones, Pencil, Star, Trash2, X } from 'lucide-vue-next'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { Sheet, SheetContent, SheetTitle, SheetDescription } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
@@ -12,6 +12,8 @@ import { useBookDetail } from '../composables/useBookDetail'
import { useCoverVersions } from '../composables/useCoverVersions'
import { bookCoverStyle } from '../lib/book-cover'
import { getFormatColor } from '../lib/format-colors'
import { FORMAT_TO_GROUP } from '@projectx/types'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps<{ bookId: number | null; open: boolean }>()
@@ -128,7 +130,13 @@ const providerLinks = computed<ProviderLink[]>(() => {
const descriptionExpanded = ref(false)
const coverLightboxOpen = ref(false)
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const primaryFile = computed(() => detail.value?.files.find((f) => f.role === 'primary') ?? detail.value?.files[0] ?? null)
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const knownFormats = computed(() => [
...new Set((detail.value?.files ?? []).filter((f) => f.format && FORMAT_TO_GROUP[f.format]).map((f) => f.format!)),
])
function providerLinkStyle(provider: string) {
const color = getProviderColor(provider)
@@ -173,14 +181,14 @@ function openDetails() {
<template>
<TooltipProvider :delay-duration="0">
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
<SheetContent side="right" class="sm:max-w-[400px] p-0 overflow-hidden">
<SheetContent side="right" class="sm:max-w-100 p-0 overflow-hidden">
<SheetTitle class="sr-only">{{ detail?.title ? `Quick view for ${detail.title}` : 'Book quick view' }}</SheetTitle>
<SheetDescription class="sr-only">Preview book details and run quick actions.</SheetDescription>
<div class="flex flex-col h-full">
<!-- Header: cover + title block -->
<div class="p-5 pt-10 border-b shrink-0">
<div v-if="loading" class="flex gap-4 items-start">
<Skeleton class="w-24 rounded shrink-0" style="aspect-ratio: 2/3" />
<Skeleton class="w-24 rounded shrink-0" :style="{ aspectRatio: coverAspectRatio }" />
<div class="flex-1 space-y-2 pt-1">
<Skeleton class="h-4 w-full" />
<Skeleton class="h-3 w-3/4" />
@@ -192,14 +200,13 @@ function openDetails() {
<!-- Cover -->
<div
class="w-24 shrink-0 rounded overflow-hidden shadow-md cursor-zoom-in"
style="aspect-ratio: 2/3"
:style="coverLoaded ? {} : coverStyle"
:style="[{ aspectRatio: coverAspectRatio }, coverLoaded ? {} : coverStyle]"
@click="coverLoaded && !coverFailed && (coverLightboxOpen = true)"
>
<img
v-if="!coverFailed"
:src="coverSrc!"
class="w-full h-full object-cover transition-opacity duration-200"
class="w-full h-full object-contain transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
:alt="detail.title ?? ''"
@load="coverLoaded = true"
@@ -265,14 +272,15 @@ function openDetails() {
</template>
<template v-else-if="detail">
<!-- Meta chips -->
<!-- Format badges + meta chips -->
<div class="flex flex-wrap gap-1.5">
<span
v-for="file in detail.files"
:key="file.id"
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-muted text-muted-foreground"
v-for="fmt in knownFormats"
:key="fmt"
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded border"
:style="formatBadgeStyle(fmt)"
>
{{ file.format ?? '?' }}
{{ fmt }}
</span>
<span v-if="detail.pageCount" class="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted text-muted-foreground">
{{ detail.pageCount }} pages
@@ -324,7 +332,7 @@ function openDetails() {
<!-- Genres -->
<div v-if="detail.genres.length" class="flex flex-wrap gap-1.5">
<span v-for="genre in detail.genres" :key="genre" class="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary">
<span v-for="genre in detail.genres" :key="genre" class="text-[11px] px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{{ genre }}
</span>
</div>
@@ -356,8 +364,9 @@ function openDetails() {
:disabled="!primaryFile"
@click="openBook"
>
<BookOpen class="size-4" />
Read
<Headphones v-if="isPrimaryAudio" class="size-4" />
<BookOpen v-else class="size-4" />
{{ isPrimaryAudio ? 'Listen' : 'Read' }}
</button>
<button
class="flex flex-1 items-center justify-center text-primary-foreground gap-2 h-9 rounded-md bg-sky-600 text-sm font-medium hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-sky-600 transition-colors"
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, inject, ref } from 'vue'
import { useElementSize, useWindowSize } from '@vueuse/core'
import { RecycleScroller } from 'vue-virtual-scroller'
import type { BookCard } from '@projectx/types'
import BookCoverCard from './BookCoverCard.vue'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
@@ -63,8 +64,11 @@ const gridItems = computed(() => {
const itemSecondarySize = computed(() => {
return Math.max(1, Math.floor((availableWidth.value + gapPx.value) / gridItems.value))
})
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const aspectMultiplier = computed(() => (coverAspectRatio.value === '1/1' ? 1 : 3 / 2))
const cardWidth = computed(() => Math.max(1, itemSecondarySize.value - gapPx.value))
const cardHeight = computed(() => Math.max(1, Math.round((cardWidth.value * 3) / 2)))
const cardHeight = computed(() => Math.max(1, Math.round(cardWidth.value * aspectMultiplier.value)))
const itemSize = computed(() => cardHeight.value + gapPx.value)
const buffer = computed(() => Math.max(itemSize.value * 2, 240))
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import { computed, inject, ref, onUnmounted } from 'vue'
import { Image, ImagePlus, Link, Loader2, RotateCcw, Search, Upload, X } from 'lucide-vue-next'
import type { BookDetail } from '@projectx/types'
import { FORMAT_TO_GROUP } from '@projectx/types'
@@ -7,6 +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 CoverSearchDrawer from './CoverSearchDrawer.vue'
const props = defineProps<{ book: BookDetail }>()
@@ -42,13 +43,7 @@ const activeSrc = computed(() => previewSrc.value ?? coverUrl(props.book.id, 'co
const hasPending = computed(() => !!pendingFile.value || !!pendingUrl.value)
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const coverNaturalWidth = ref(0)
const coverNaturalHeight = ref(0)
const coverAspectRatio = computed(() => {
if (!isPrimaryAudio.value) return '2/3'
if (coverNaturalWidth.value > 0 && coverNaturalHeight.value > coverNaturalWidth.value) return '2/3'
return '1/1'
})
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
function cancelPending() {
clearPending()
@@ -89,12 +84,6 @@ async function handleRevert() {
const lightboxOpen = ref(false)
function handleCoverLoad(e: Event) {
const img = e.target as HTMLImageElement
coverNaturalWidth.value = img.naturalWidth
coverNaturalHeight.value = img.naturalHeight
}
defineExpose({ setUrl, hasPending, confirm })
onUnmounted(() => clearTimeout(debounceTimer))
@@ -108,7 +97,7 @@ onUnmounted(() => clearTimeout(debounceTimer))
:style="{ aspectRatio: coverAspectRatio }"
@click="lightboxOpen = true"
>
<img :src="activeSrc" :alt="book.title ?? ''" class="w-full h-full object-contain" @error="hideOnError" @load="handleCoverLoad" />
<img :src="activeSrc" :alt="book.title ?? ''" class="w-full h-full object-contain" @error="hideOnError" />
</div>
<!-- Lightbox -->
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Check, ChevronDown, FolderPlus, MoreHorizontal, Pencil, Star, Trash2, TriangleAlert, X } from 'lucide-vue-next'
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
@@ -7,6 +7,7 @@ import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { getFormatColor } from '@/features/book/lib/format-colors'
import { getProviderColor } 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 { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@projectx/types'
import type { BookDetail, BookKoboState, ReadStatus } from '@projectx/types'
import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/features/book/composables/useBookStatus'
@@ -76,8 +77,6 @@ const coverLoaded = ref(false)
const coverFailed = ref(false)
const coverLightboxOpen = ref(false)
const descriptionExpanded = ref(false)
const coverNaturalWidth = ref(0)
const coverNaturalHeight = ref(0)
const genresExpanded = ref(false)
const genreMeasureContainer = ref<HTMLElement | null>(null)
const genreHiddenCount = ref(0)
@@ -226,13 +225,8 @@ const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(prop
const { coverUrl } = useCoverVersions()
const coverSrc = computed(() => coverUrl(props.book.id, 'cover'))
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const coverAspectRatio = computed(() => {
if (!isPrimaryAudio.value) return '2/3'
if (coverNaturalWidth.value > 0 && coverNaturalHeight.value > coverNaturalWidth.value) return '2/3'
return '1/1'
})
const readableFiles = computed(() => props.book.files.filter((f) => f.format && READER_OPENABLE_FORMATS.has(f.format)))
// For multi-file audiobooks, collapse all tracks into one representative entry.
@@ -252,7 +246,7 @@ const hasMultipleFiles = computed(() => openableFiles.value.length > 1)
const authorLine = computed(() => props.book.authors.map((a) => a.name).join(', ') || null)
const narratorLine = computed(() => props.book.narrators?.map((n) => n.name).join(', ') || null)
const formats = computed(() => {
const all = [...new Set(props.book.files.map((f) => f.format ?? '?'))]
const all = [...new Set(props.book.files.filter((f) => f.format && FORMAT_TO_GROUP[f.format]).map((f) => f.format!))]
const priority = props.book.formatPriority
const sorted = priority.length
? all.sort((a, b) => {
@@ -516,10 +510,7 @@ function handleDeleteFromMenu() {
promptDelete(props.book.id)
}
function handleCoverLoad(e: Event) {
const img = e.target as HTMLImageElement
coverNaturalWidth.value = img.naturalWidth
coverNaturalHeight.value = img.naturalHeight
function handleCoverLoad() {
coverLoaded.value = true
}
@@ -636,8 +627,8 @@ watch(
<div class="md:w-56 shrink-0 md:sticky md:top-4 md:self-start">
<div class="max-w-48 mx-auto md:max-w-none">
<div
class="group relative w-full rounded-sm overflow-hidden shadow-md cursor-zoom-in bg-muted/50"
:style="[{ aspectRatio: coverAspectRatio }, coverLoaded ? {} : coverStyle]"
class="group relative w-full rounded-sm overflow-hidden shadow-md cursor-zoom-in"
:style="[{ aspectRatio: coverAspectRatio }, !coverLoaded || coverFailed ? coverStyle : {}]"
@click="coverLoaded && !coverFailed && (coverLightboxOpen = true)"
>
<Tooltip>
@@ -651,10 +642,18 @@ watch(
</TooltipTrigger>
<TooltipContent>Edit cover</TooltipContent>
</Tooltip>
<!-- Blurred background fill for mismatched aspect ratios -->
<img
v-if="!coverFailed"
:src="coverSrc"
class="w-full h-full object-contain transition-opacity duration-200"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-lg brightness-50 transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
aria-hidden="true"
/>
<img
v-if="!coverFailed"
:src="coverSrc"
class="absolute inset-0 w-full h-full object-contain transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
:alt="book.title ?? ''"
@load="handleCoverLoad"
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, inject, reactive, ref, watch } from 'vue'
import { ArrowLeft, CopyCheck, Copy, CheckCheck } from 'lucide-vue-next'
import type { MetadataCandidate, MetadataProviderInfo, MetadataProviderKey, MetadataSource, ProviderIds } from '@projectx/types'
import { useMetadataDiff, type DiffFieldKey, type MetadataPatch } from '../../../composables/useMetadataDiff'
import { getProviderLabel, getProviderColor, hideOnError, providerBadgeStyle, providerActivePillStyle } from '../../../lib/metadata-fetch'
import MetadataDiffRow from './MetadataDiffRow.vue'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
const props = defineProps<{
current: MetadataSource
@@ -22,6 +23,8 @@ const emit = defineEmits<{
apply: [{ formPatch: MetadataPatch; coverUrl?: string }]
}>()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const activeProvider = ref<MetadataProviderKey>(props.initialCandidate.provider)
// Tracks which specific result the user has picked per provider tab
@@ -113,7 +116,11 @@ function apply() {
<ArrowLeft class="size-4 transition-transform group-hover:-translate-x-0.5" />
{{ backLabel ?? 'Results' }}
</button>
<div v-if="activeCandidate.coverUrl" class="shrink-0 w-8 rounded overflow-hidden bg-muted ring-1 ring-border" style="aspect-ratio: 2/3">
<div
v-if="activeCandidate.coverUrl"
class="shrink-0 w-8 rounded overflow-hidden bg-muted ring-1 ring-border"
:style="{ aspectRatio: coverAspectRatio }"
>
<img
:src="activeCandidate.coverUrl"
alt=""
@@ -175,9 +182,11 @@ function apply() {
:key="c.providerId ?? i"
class="shrink-0 rounded-md overflow-hidden bg-muted ring-1 transition-all active:scale-95"
:class="isSelectedForTab(c) ? 'ring-2' : 'ring-border/60 hover:ring-border'"
:style="isSelectedForTab(c) ? { outline: 'none', boxShadow: `0 0 0 2px ${getProviderColor(c.provider)}` } : {}"
:style="[
{ width: '28px', aspectRatio: coverAspectRatio },
isSelectedForTab(c) ? { outline: 'none', boxShadow: `0 0 0 2px ${getProviderColor(c.provider)}` } : {},
]"
:title="`${c.title}${c.publishedYear ? ' · ' + c.publishedYear : ''}`"
style="width: 28px; aspect-ratio: 2/3"
@click="selectResultForTab(c)"
>
<img v-if="c.coverUrl" :src="c.coverUrl" alt="" class="w-full h-full object-cover" @error="hideOnError" />
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, inject, ref } from 'vue'
import { ArrowLeft, RotateCcw, CheckCircle2, X, ZoomIn, Layers } from 'lucide-vue-next'
import type { MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
import type { DiffField, DiffFieldKey } from '../../../composables/useMetadataDiff'
import { hideOnError, providerBadgeStyle } from '../../../lib/metadata-fetch'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
const props = defineProps<{
field: DiffField
@@ -17,6 +18,8 @@ const emit = defineEmits<{
pickFromProvider: [key: DiffFieldKey, provider: MetadataProviderKey]
}>()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const lightboxSrc = ref<string | null>(null)
const pickedProviderLabel = computed(() => {
@@ -42,19 +45,24 @@ function handlePickFromProvider(provider: MetadataProviderKey) {
<div
class="w-16 rounded-lg overflow-hidden bg-muted transition-all duration-300 shadow-sm ring-1 relative group"
:class="field.isPicked ? 'ring-primary ring-2' : field.bookValue ? 'ring-border cursor-zoom-in' : 'ring-border opacity-50'"
style="aspect-ratio: 2/3"
:style="{ aspectRatio: coverAspectRatio }"
@click="
field.isPicked ? (lightboxSrc = field.pickedDisplay || field.candidateDisplay) : field.bookValue ? (lightboxSrc = field.bookValue) : null
"
>
<img
v-if="field.isPicked && field.pickedDisplay"
:src="field.pickedDisplay"
alt="Preview"
class="w-full h-full object-cover"
@error="hideOnError"
/>
<img v-else-if="field.bookValue" :src="field.bookValue" alt="Current cover" class="w-full h-full object-cover" @error="hideOnError" />
<template v-if="field.isPicked && field.pickedDisplay">
<img
:src="field.pickedDisplay"
alt=""
aria-hidden="true"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-75"
/>
<img :src="field.pickedDisplay" alt="Preview" class="relative w-full h-full object-contain" @error="hideOnError" />
</template>
<template v-else-if="field.bookValue">
<img :src="field.bookValue" alt="" aria-hidden="true" class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-75" />
<img :src="field.bookValue" alt="Current cover" class="relative w-full h-full object-contain" @error="hideOnError" />
</template>
<div v-else class="w-full h-full bg-linear-to-br from-muted to-muted-foreground/10" />
<div
v-if="field.isPicked || field.bookValue"
@@ -109,8 +117,8 @@ function handlePickFromProvider(provider: MetadataProviderKey) {
:class="field.pickedProvider === pv.provider ? 'bg-primary/8 ring-1 ring-inset ring-primary/20' : ''"
@click="handlePickFromProvider(pv.provider)"
>
<span class="shrink-0 w-8 rounded overflow-hidden bg-muted" style="aspect-ratio: 2/3">
<img :src="pv.display" alt="" class="w-full h-full object-cover" @error="hideOnError" />
<span class="relative shrink-0 w-8 rounded overflow-hidden bg-muted" :style="{ aspectRatio: coverAspectRatio }">
<img :src="pv.display" alt="" class="w-full h-full object-contain" @error="hideOnError" />
</span>
<span
class="inline-flex items-center text-[9px] font-bold px-1.5 py-0.5 rounded-md shrink-0"
@@ -134,10 +142,18 @@ function handlePickFromProvider(provider: MetadataProviderKey) {
: 'ring-border cursor-zoom-in'
: 'ring-border opacity-50'
"
style="aspect-ratio: 2/3"
:style="{ aspectRatio: coverAspectRatio }"
@click="field.candidateDisplay ? (lightboxSrc = field.candidateDisplay) : null"
>
<img v-if="field.candidateDisplay" :src="field.candidateDisplay" alt="New cover" class="w-full h-full object-cover" @error="hideOnError" />
<template v-if="field.candidateDisplay">
<img
:src="field.candidateDisplay"
alt=""
aria-hidden="true"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-75"
/>
<img :src="field.candidateDisplay" alt="New cover" class="relative w-full h-full object-contain" @error="hideOnError" />
</template>
<div v-else class="w-full h-full bg-linear-to-br from-muted to-muted-foreground/10" />
<div
v-if="field.candidateDisplay"
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, inject, ref } from 'vue'
import { BookOpen } from 'lucide-vue-next'
import type { MetadataCandidate, MetadataProviderInfo } from '@projectx/types'
import { getProviderLabel, hideOnError, providerBadgeStyle } from '../../../lib/metadata-fetch'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
const props = defineProps<{
candidate: MetadataCandidate
@@ -11,6 +12,8 @@ const props = defineProps<{
defineEmits<{ select: [MetadataCandidate] }>()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const providerLabel = computed(() => getProviderLabel(props.candidate.provider, props.providers))
</script>
@@ -20,12 +23,12 @@ const providerLabel = computed(() => getProviderLabel(props.candidate.provider,
@click="$emit('select', candidate)"
>
<!-- Cover -->
<span class="relative shrink-0 rounded-lg overflow-hidden bg-muted block shadow-sm" style="width: 64px; aspect-ratio: 2/3">
<span class="relative shrink-0 rounded-lg overflow-hidden bg-muted block shadow-sm" :style="{ width: '64px', aspectRatio: coverAspectRatio }">
<img
v-if="candidate.coverUrl"
:src="candidate.coverUrl"
:alt="candidate.title"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
class="w-full h-full object-contain transition-transform duration-500 group-hover:scale-105"
@error="hideOnError"
/>
<span v-else class="absolute inset-0 flex items-center justify-center bg-linear-to-br from-surface-2 to-surface-4">
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { computed, inject, reactive, ref } from 'vue'
import { Search, BookOpen, Loader2 } from 'lucide-vue-next'
import type { MetadataCandidate, MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
import MetadataResultCard from './MetadataResultCard.vue'
import { providerActivePillStyle } from '../../../lib/metadata-fetch'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
const props = defineProps<{
searchDefaults: { title?: string; author?: string; isbn?: string }
@@ -22,6 +23,8 @@ const emit = defineEmits<{
select: [MetadataCandidate]
}>()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const form = reactive({
title: props.searchDefaults.title ?? '',
author: props.searchDefaults.author ?? '',
@@ -119,7 +122,7 @@ function runSearch() {
<!-- Skeleton grid while loading with no results yet -->
<div v-if="isStreaming && !filteredResults.length" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div v-for="n in 8" :key="n" class="rounded-xl border border-border/40 bg-card overflow-hidden animate-pulse flex gap-3 p-2.5">
<div class="rounded-lg bg-muted shrink-0" style="width: 64px; aspect-ratio: 2/3" />
<div class="rounded-lg bg-muted shrink-0" :style="{ width: '64px', aspectRatio: coverAspectRatio }" />
<div class="flex-1 flex flex-col justify-center gap-2 py-1">
<div class="h-3 bg-muted rounded-md w-full" />
<div class="h-2.5 bg-muted rounded-md w-3/4" />
@@ -0,0 +1,5 @@
import type { InjectionKey, Ref } from 'vue'
import type { CoverAspectRatio } from '@projectx/types'
export const COVER_ASPECT_RATIO_KEY: InjectionKey<Readonly<Ref<CoverAspectRatio>>> = Symbol('coverAspectRatio')
export const DEFAULT_COVER_ASPECT_RATIO: CoverAspectRatio = '2/3'
@@ -4,15 +4,23 @@ import * as LucideIcons from 'lucide-vue-next'
import { Library, Search, X } from 'lucide-vue-next'
import { RecycleScroller } from 'vue-virtual-scroller'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { CoverAspectRatio } from '@projectx/types'
const ASPECT_RATIO_OPTIONS: { value: CoverAspectRatio; label: string }[] = [
{ value: '2/3', label: 'Portrait' },
{ value: '1/1', label: 'Square' },
]
const props = defineProps<{
name: string
icon: string | null
coverAspectRatio: CoverAspectRatio
}>()
const emit = defineEmits<{
'update:name': [value: string]
'update:icon': [value: string | null]
'update:coverAspectRatio': [value: CoverAspectRatio]
}>()
// ── Icon grid ──────────────────────────────────────────────────────────────
@@ -74,6 +82,26 @@ const selectedIconComponent = computed(() => (props.icon ? (LucideIcons as Recor
</div>
</div>
<!-- Cover aspect ratio -->
<div>
<label class="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Cover style</label>
<div class="flex gap-3">
<button
v-for="option in ASPECT_RATIO_OPTIONS"
:key="option.value"
class="flex flex-1 flex-col items-center gap-2 p-3 rounded-lg border-2 transition-colors"
:class="
coverAspectRatio === option.value ? 'border-primary bg-primary/8' : 'border-border hover:border-muted-foreground/40 hover:bg-muted/40'
"
@click="emit('update:coverAspectRatio', option.value)"
>
<div class="w-8 rounded-sm bg-muted-foreground/25" :style="{ aspectRatio: option.value }" />
<span class="text-xs font-medium">{{ option.label }}</span>
</button>
</div>
<p class="mt-2 text-xs text-muted-foreground">Use Portrait for ebook or mixed libraries. Square works better for audiobook-only libraries.</p>
</div>
<!-- Icon picker (fills remaining height) -->
<div class="flex-1 flex flex-col min-h-0">
<div class="flex items-center justify-between mb-2">
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { X, ChevronLeft, ChevronRight, Check, Info, FolderOpen, ScanLine, Clock, Users } from 'lucide-vue-next'
import type { Library } from '@projectx/types'
import type { CoverAspectRatio, Library } from '@projectx/types'
import { api } from '@/lib/api'
import { useLibraryCreator } from '../composables/useLibraryCreator'
import LibraryCreatorDetails from './LibraryCreatorDetails.vue'
@@ -111,7 +111,7 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown))
const title = computed(() => (props.library ? `Edit: ${props.library.name}` : 'Create Library'))
const sectionProps = computed(() => ({
details: { name: form.name, icon: form.icon },
details: { name: form.name, icon: form.icon, coverAspectRatio: form.coverAspectRatio },
folders: { folders: form.folders, prescanResult: prescanResult.value, prescanLoading: prescanLoading.value },
scanner: {
metadataPrecedence: form.metadataPrecedence,
@@ -130,6 +130,7 @@ function getSectionProps(id: SectionId) {
function onSectionEvent(id: SectionId, event: string, value: unknown) {
if (event === 'update:name') form.name = value as string
else if (event === 'update:icon') form.icon = value as string | null
else if (event === 'update:coverAspectRatio') form.coverAspectRatio = value as CoverAspectRatio
else if (event === 'update:folders') form.folders = value as string[]
else if (event === 'prescan') creator.runPrescan()
else if (event === 'update:metadataPrecedence') form.metadataPrecedence = value as string[]
@@ -228,6 +229,7 @@ function onSectionEvent(id: SectionId, event: string, value: unknown) {
@update:formatPriority="onSectionEvent(activeId, 'update:formatPriority', $event)"
@update:allowedFormats="onSectionEvent(activeId, 'update:allowedFormats', $event)"
@update:excludePatterns="onSectionEvent(activeId, 'update:excludePatterns', $event)"
@update:coverAspectRatio="onSectionEvent(activeId, 'update:coverAspectRatio', $event)"
@update:watch="onSectionEvent(activeId, 'update:watch', $event)"
@update:autoScanCronExpression="onSectionEvent(activeId, 'update:autoScanCronExpression', $event)"
/>
@@ -348,6 +350,7 @@ function onSectionEvent(id: SectionId, event: string, value: unknown) {
@update:formatPriority="onSectionEvent(activeId, 'update:formatPriority', $event)"
@update:allowedFormats="onSectionEvent(activeId, 'update:allowedFormats', $event)"
@update:excludePatterns="onSectionEvent(activeId, 'update:excludePatterns', $event)"
@update:coverAspectRatio="onSectionEvent(activeId, 'update:coverAspectRatio', $event)"
@update:watch="onSectionEvent(activeId, 'update:watch', $event)"
@update:autoScanCronExpression="onSectionEvent(activeId, 'update:autoScanCronExpression', $event)"
/>
@@ -1,7 +1,7 @@
import { reactive, ref } from 'vue'
import { api } from '@/lib/api'
import { DEFAULT_FORMAT_PRIORITY, FORMAT_LABELS } from '@projectx/types'
import type { Library, OrganizationMode, PrescanResult } from '@projectx/types'
import type { CoverAspectRatio, Library, OrganizationMode, PrescanResult } from '@projectx/types'
export { DEFAULT_FORMAT_PRIORITY, FORMAT_LABELS }
@@ -17,6 +17,7 @@ function blankForm() {
name: '',
icon: null as string | null,
displayOrder: 0,
coverAspectRatio: '2/3' as CoverAspectRatio,
folders: [] as string[],
watch: false,
autoScanCronExpression: null as string | null,
@@ -51,6 +52,7 @@ export function useLibraryCreator() {
form.name = library.name
form.icon = library.icon ?? null
form.displayOrder = library.displayOrder
form.coverAspectRatio = library.coverAspectRatio
form.folders = library.folders.map((f) => f.path)
form.watch = library.watch
form.autoScanCronExpression = library.autoScanCronExpression ?? null
+13 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, provide, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { BookDetail } from '@projectx/types'
import BookDetailLayout from '@/features/book/components/detail/BookDetailLayout.vue'
@@ -12,9 +12,21 @@ import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
import { usePageTitle } from '@/composables/usePageTitle'
import { normalizeBookDetailTab } from '@/features/book/lib/book-detail-tabs'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
const route = useRoute()
const { hasPermission } = usePermissions()
const { libraries } = useLibraries()
provide(
COVER_ASPECT_RATIO_KEY,
computed(() => {
const libraryId = detail.value?.libraryId
const library = libraryId != null ? libraries.value.find((l) => l.id === libraryId) : null
return library?.coverAspectRatio ?? DEFAULT_COVER_ASPECT_RATIO
}),
)
const bookId = computed(() => Number(route.params.bookId))
const tab = computed(() => normalizeBookDetailTab(route.query.tab))
+3 -1
View File
@@ -20,14 +20,16 @@ import { useBookSelection } from '@/features/book/composables/useBookSelection'
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
import { useBookBulkActions } from '@/features/book/composables/useBookBulkActions'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useViewDisplaySettings } from '@/composables/useViewDisplaySettings'
import { usePageTitle } from '@/composables/usePageTitle'
import type { BookCard } from '@projectx/types'
const route = useRoute()
const router = useRouter()
const { coverSize, gridGap, viewMode } = useDisplaySettings()
const { viewMode } = useDisplaySettings()
const collectionId = computed(() => Number(route.params.id))
const { coverSize, gridGap } = useViewDisplaySettings('collection', collectionId)
const { collections, fetchCollections, removeBooksFromCollection } = useCollections()
const collection = computed(() => collections.value.find((c) => c.id === collectionId.value))
const pageTitle = computed(() => {
+10 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import { computed, onMounted, onUnmounted, provide, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowUpDown, Bookmark, BookmarkCheck, BookOpen, Filter, Telescope, X } from 'lucide-vue-next'
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
@@ -23,19 +23,22 @@ import { useBookBulkActions } from '@/features/book/composables/useBookBulkActio
import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useViewDisplaySettings } from '@/composables/useViewDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
import { SORT_FIELD_LABELS } from '@/features/book/lib/filter-labels'
import { usePageTitle } from '@/composables/usePageTitle'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
import type { GroupRule, SortSpec } from '@projectx/types'
const route = useRoute()
const router = useRouter()
const { coverSize, gridGap, viewMode } = useDisplaySettings()
const { viewMode } = useDisplaySettings()
const { libraries } = useLibraries()
const libraryId = shallowRef<number | null>(route.params.id ? Number(route.params.id) : null)
const { coverSize, gridGap } = useViewDisplaySettings('library', libraryId)
const currentLibrary = computed(() => libraries.value.find((l) => l.id === libraryId.value))
const title = computed(() => currentLibrary.value?.name ?? 'Library')
@@ -46,6 +49,11 @@ const pageTitle = computed(() => {
})
usePageTitle(pageTitle)
provide(
COVER_ASPECT_RATIO_KEY,
computed(() => currentLibrary.value?.coverAspectRatio ?? DEFAULT_COVER_ASPECT_RATIO),
)
const { items: books, total, loading, error, filter, sort, hasMore, load, clear } = useBookQuery(libraryId)
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
+3 -1
View File
@@ -15,6 +15,7 @@ import { useLens } from '@/features/lens/composables/useLens'
import { useLenses } from '@/features/lens/composables/useLenses'
import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useViewDisplaySettings } from '@/composables/useViewDisplaySettings'
import { useBookSelection } from '@/features/book/composables/useBookSelection'
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
import { useBookBulkActions } from '@/features/book/composables/useBookBulkActions'
@@ -25,9 +26,10 @@ import type { BookCard, GroupRule, SortField } from '@projectx/types'
const route = useRoute()
const router = useRouter()
const { coverSize, gridGap, viewMode, lensFilterExpanded } = useDisplaySettings()
const { viewMode, lensFilterExpanded } = useDisplaySettings()
const lensId = computed(() => Number(route.params.id))
const { coverSize, gridGap } = useViewDisplaySettings('lens', lensId)
const { items: books, total, loading, hasMore, load } = useLens(lensId)
const { setBookContext, registerLoadMore } = useBookNavigation()
+2
View File
@@ -1,4 +1,5 @@
export type OrganizationMode = "auto" | "book_per_file" | "book_per_folder";
export type CoverAspectRatio = "2/3" | "1/1";
export const DEFAULT_FORMAT_PRIORITY = [
"epub", "pdf", "cbz", "cbr", "cb7",
@@ -36,6 +37,7 @@ export interface Library {
name: string;
icon?: string | null;
displayOrder: number;
coverAspectRatio: CoverAspectRatio;
watch: boolean;
autoScanCronExpression?: string | null;
metadataPrecedence: string[];
@@ -102,6 +102,7 @@ CREATE TABLE "libraries" (
"name" varchar(255) NOT NULL,
"icon" varchar(100),
"display_order" integer DEFAULT 0 NOT NULL,
"cover_aspect_ratio" varchar(10) DEFAULT '2/3' NOT NULL,
"watch" boolean DEFAULT false NOT NULL,
"auto_scan_cron_expression" text,
"metadata_precedence" jsonb DEFAULT '["folderStructure","embedded","nfoFile","opfFile","sidecar"]'::jsonb NOT NULL,
@@ -1,5 +1,5 @@
{
"id": "2e38901e-f8a5-4f93-8564-e0f8f44e1883",
"id": "00255418-cbd4-4a15-8395-a63bca8ca17f",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -768,6 +768,13 @@
"notNull": true,
"default": 0
},
"cover_aspect_ratio": {
"name": "cover_aspect_ratio",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true,
"default": "'2/3'"
},
"watch": {
"name": "watch",
"type": "boolean",
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1774487725854,
"when": 1774615690041,
"tag": "0000_baseline",
"breakpoints": true
}
+1
View File
@@ -6,6 +6,7 @@ export const libraries = pgTable('libraries', {
name: varchar('name', { length: 255 }).notNull(),
icon: varchar('icon', { length: 100 }),
displayOrder: integer('display_order').notNull().default(0),
coverAspectRatio: varchar('cover_aspect_ratio', { length: 10 }).notNull().default('2/3'),
// File watching & scheduling
watch: boolean('watch').notNull().default(false),
@@ -36,6 +36,10 @@ export class CreateLibraryDto {
@IsNotEmpty({ each: true })
folders: string[];
@IsOptional()
@IsIn(['2/3', '1/1'])
coverAspectRatio?: string;
@IsOptional()
@IsBoolean()
watch?: boolean;
@@ -38,6 +38,10 @@ export class UpdateLibraryDto {
@IsNotEmpty({ each: true })
folders?: string[];
@IsOptional()
@IsIn(['2/3', '1/1'])
coverAspectRatio?: string;
@IsOptional()
@IsBoolean()
watch?: boolean;
@@ -19,6 +19,7 @@ vi.mock('sharp', () => {
import { mkdir, readFile, readdir, writeFile } from 'fs/promises';
import { join } from 'path';
import sharp from 'sharp';
import { extractCb7Cover } from './cover-cb7';
import { extractCbrCover } from './cover-cbr';
import { extractCbzCover } from './cover-cbz';
@@ -100,6 +101,14 @@ describe('generateThumbnail', () => {
const result = await generateThumbnail(JPEG_BYTES);
expect(result).toBeInstanceOf(Buffer);
});
it('resizes with inside-fit to avoid cropping', async () => {
await generateThumbnail(JPEG_BYTES);
const sharpMock = sharp as unknown as MockedFunction<typeof sharp>;
expect(sharpMock).toHaveBeenCalledWith(JPEG_BYTES);
const chain = sharpMock.mock.results[0]?.value as { resize: Mock };
expect(chain.resize).toHaveBeenCalledWith(400, 600, { fit: 'inside', withoutEnlargement: true });
});
});
// ── extractCover (routing) ────────────────────────────────────────────────────
+1 -1
View File
@@ -19,7 +19,7 @@ export function imageExt(bytes: Buffer): string {
}
export async function generateThumbnail(bytes: Buffer): Promise<Buffer> {
return sharp(bytes).resize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, { fit: 'cover', position: 'top' }).jpeg({ quality: 90 }).toBuffer();
return sharp(bytes).resize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, { fit: 'inside', withoutEnlargement: true }).jpeg({ quality: 90 }).toBuffer();
}
/**