mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(cover): add per-library aspect ratio and per-view cover size
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1774487725854,
|
||||
"when": 1774615690041,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -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) ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user