feat(cover): add online cover search with provider infrastructure

This commit is contained in:
neon
2026-03-06 04:21:48 -06:00
parent ca7a1b0095
commit 78051d0b42
9 changed files with 461 additions and 6 deletions
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import { Image, ImagePlus, Link, Loader2, RotateCcw, Upload, X } from 'lucide-vue-next'
import { Image, ImagePlus, Link, Loader2, RotateCcw, Search, Upload, X } from 'lucide-vue-next'
import type { BookDetail } from '@projectx/types'
import { hideOnError } from '../../../lib/metadata-fetch'
import { useCoverEditor } from '../../../composables/useCoverEditor'
import { useCoverVersions } from '../../../composables/useCoverVersions'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import CoverSearchDrawer from './CoverSearchDrawer.vue'
const props = defineProps<{ book: BookDetail }>()
const emit = defineEmits<{ coverChanged: ['extracted' | 'custom' | null] }>()
@@ -32,10 +33,13 @@ async function reExtractCover() {
const mode = ref<'file' | 'url'>('file')
const urlInput = ref('')
const isSearchOpen = ref(false)
let debounceTimer: ReturnType<typeof setTimeout>
const activeSrc = computed(() => previewSrc.value ?? coverUrl(props.book.id, 'cover'))
const hasPending = computed(() => !!pendingFile.value || !!pendingUrl.value)
const isAudiobook = computed(() => props.book.files.some((f) => f.format === 'audiobook' || f.role === 'audiobook'))
function onFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
@@ -54,6 +58,11 @@ function switchMode(m: 'file' | 'url') {
urlInput.value = ''
}
function handleSearchSelect(url: string) {
urlInput.value = url
setUrl(url)
}
async function handleConfirm() {
const ok = await confirm()
if (ok) emit('coverChanged', 'custom')
@@ -129,7 +138,7 @@ onUnmounted(() => clearTimeout(debounceTimer))
</div>
<!-- URL input -->
<div v-else>
<div v-else class="flex flex-col gap-2">
<input
v-model="urlInput"
class="w-full h-9 rounded-lg border border-input bg-background px-3 text-xs outline-none focus:ring-1 focus:ring-ring transition-shadow"
@@ -138,6 +147,27 @@ onUnmounted(() => clearTimeout(debounceTimer))
/>
</div>
<!-- Search Button -->
<button
class="flex items-center justify-center gap-2 w-full h-9 rounded-lg border border-input bg-background text-xs font-medium hover:bg-muted transition-colors"
@click="isSearchOpen = true"
>
<Search class="size-3.5" />
Find cover online
</button>
<!-- Cover Search Drawer -->
<Teleport to="body">
<CoverSearchDrawer
v-if="isSearchOpen"
:initial-title="book.title ?? ''"
:initial-author="book.authors?.[0]?.name ?? ''"
:is-audiobook="isAudiobook"
@close="isSearchOpen = false"
@select="handleSearchSelect"
/>
</Teleport>
<!-- Error -->
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { Search, Loader2, X, Image as ImageIcon, ExternalLink, Check } from 'lucide-vue-next'
import type { CoverSearchResult } from '@projectx/types'
const props = defineProps<{
initialTitle: string
initialAuthor: string
isAudiobook: boolean
}>()
const emit = defineEmits<{
close: []
select: [string]
}>()
const searchTitle = ref(props.initialTitle)
const searchAuthor = ref(props.initialAuthor)
const isSearching = ref(false)
const searchResults = ref<CoverSearchResult[]>([])
const hasSearched = ref(false)
const titleInput = ref<HTMLInputElement | null>(null)
async function performSearch() {
if (!searchTitle.value.trim()) return
isSearching.value = true
hasSearched.value = true
searchResults.value = []
try {
const params = new URLSearchParams({
title: searchTitle.value.trim(),
author: searchAuthor.value.trim(),
isAudiobook: String(props.isAudiobook),
})
const res = await fetch(`/api/v1/books/cover/search?${params}`)
if (!res.ok) throw new Error('Search failed')
searchResults.value = await res.json()
} catch (err) {
console.error('Search error:', err)
} finally {
isSearching.value = false
}
}
function handleSelect(url: string) {
emit('select', url)
emit('close')
}
onMounted(() => {
nextTick(() => titleInput.value?.focus())
if (searchTitle.value) performSearch()
})
</script>
<template>
<div class="fixed inset-0 z-50 flex justify-end bg-black/40 backdrop-blur-sm" @click.self="emit('close')">
<div class="w-full max-w-2xl bg-background h-full shadow-2xl flex flex-col animate-in slide-in-from-right duration-300">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center gap-2">
<div class="p-2 rounded-lg bg-primary/10 text-primary">
<Search class="size-5" />
</div>
<div>
<h2 class="text-sm font-semibold">Online Search</h2>
<p class="text-[10px] text-muted-foreground uppercase tracking-wider font-medium">Search for book covers</p>
</div>
</div>
<button class="p-2 hover:bg-muted rounded-full transition-colors" @click="emit('close')">
<X class="size-5" />
</button>
</div>
<!-- Search Bar (Sticky) -->
<div class="p-4 bg-muted/30 border-b space-y-3">
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-[10px] font-bold text-muted-foreground ml-1 uppercase">Title</label>
<input
ref="titleInput"
v-model="searchTitle"
class="w-full h-10 rounded-xl border border-input bg-background px-3 text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
placeholder="e.g. Angelmaker"
@keyup.enter="performSearch"
/>
</div>
<div class="space-y-1">
<label class="text-[10px] font-bold text-muted-foreground ml-1 uppercase">Author</label>
<input
v-model="searchAuthor"
class="w-full h-10 rounded-xl border border-input bg-background px-3 text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
placeholder="e.g. Nick Harkaway"
@keyup.enter="performSearch"
/>
</div>
</div>
<button
class="flex items-center justify-center gap-2 w-full h-10 rounded-xl bg-primary text-primary-foreground text-sm font-semibold hover:bg-primary/90 transition-all shadow-sm active:scale-[0.98]"
:disabled="isSearching"
@click="performSearch"
>
<Loader2 v-if="isSearching" class="size-4 animate-spin" />
<Search v-else class="size-4" />
{{ isSearching ? 'Searching...' : 'Find covers' }}
</button>
</div>
<!-- Results Scroll Area -->
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar">
<div v-if="isSearching" class="grid grid-cols-3 gap-4">
<div v-for="i in 9" :key="i" class="aspect-[2/3] rounded-xl bg-muted animate-pulse" />
</div>
<div v-else-if="searchResults.length > 0" class="grid grid-cols-3 gap-4">
<div
v-for="res in searchResults"
:key="String(res.url)"
class="group relative aspect-[2/3] rounded-xl overflow-hidden bg-muted border border-border/50 hover:border-primary/50 hover:ring-4 hover:ring-primary/10 transition-all cursor-pointer"
@click="handleSelect(String(res.url))"
>
<img :src="res.previewUrl" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" loading="lazy" />
<!-- Hover Overlay -->
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2">
<div class="p-2 rounded-full bg-primary text-white scale-75 group-hover:scale-100 transition-transform">
<Check class="size-5" />
</div>
<span class="text-xs font-bold text-white uppercase tracking-widest">Select Cover</span>
</div>
<!-- Resolution Badge -->
<div class="absolute top-2 right-2 px-1.5 py-0.5 rounded-md text-[10px] font-bold text-white backdrop-blur-md shadow-sm"
:class="res.width >= 1000 ? 'bg-green-500/80' : 'bg-black/50'">
{{ res.width }}x{{ res.height }}
</div>
<!-- Source Badge -->
<div class="absolute bottom-2 left-2 px-1.5 py-0.5 rounded-md text-[10px] font-bold text-white bg-black/50 backdrop-blur-md shadow-sm">
{{ res.source }}
</div>
</div>
</div>
<!-- Empty States -->
<div v-else-if="hasSearched" class="h-full flex flex-col items-center justify-center text-center space-y-3 opacity-60 py-12">
<div class="p-4 rounded-full bg-muted">
<ImageIcon class="size-10 text-muted-foreground" />
</div>
<div>
<h3 class="font-semibold">No results found</h3>
<p class="text-xs text-muted-foreground">Try adjusting your search terms</p>
</div>
</div>
<div v-else class="h-full flex flex-col items-center justify-center text-center space-y-3 opacity-60 py-12">
<div class="p-4 rounded-full bg-muted">
<Search class="size-10 text-muted-foreground" />
</div>
<div>
<h3 class="font-semibold">Ready to search</h3>
<p class="text-xs text-muted-foreground">Enter a title and author above</p>
</div>
</div>
</div>
<!-- Footer / Hint -->
<div class="p-4 border-t bg-muted/10">
<p class="text-[10px] text-center text-muted-foreground flex items-center justify-center gap-1">
<ImageIcon class="size-3" />
Tip: High resolution covers are marked in green
</p>
</div>
</div>
</div>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.1);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.2);
}
</style>
+1
View File
@@ -169,6 +169,7 @@ const activeFilterCount = computed(() => filter.value?.rules?.length ?? 0)
function clearFilters() {
filter.value = undefined
forgetSavedFilter()
}
const sentinel = ref<HTMLElement | null>(null)