mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(cover): add online cover search with provider infrastructure
This commit is contained in:
@@ -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>
|
||||
@@ -169,6 +169,7 @@ const activeFilterCount = computed(() => filter.value?.rules?.length ?? 0)
|
||||
|
||||
function clearFilters() {
|
||||
filter.value = undefined
|
||||
forgetSavedFilter()
|
||||
}
|
||||
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
|
||||
Reference in New Issue
Block a user