mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(metadata): add search drawer with Google Books and Goodreads
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { computed, ref, onUnmounted } from 'vue'
|
||||
import { ImagePlus, Link, RotateCcw, 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'
|
||||
|
||||
@@ -32,6 +33,7 @@ function onUrlInput() {
|
||||
|
||||
function switchMode(m: 'file' | 'url') {
|
||||
mode.value = m
|
||||
clearTimeout(debounceTimer)
|
||||
clearPending()
|
||||
urlInput.value = ''
|
||||
}
|
||||
@@ -48,6 +50,8 @@ async function handleRevert() {
|
||||
|
||||
const lightboxOpen = ref(false)
|
||||
|
||||
defineExpose({ setUrl })
|
||||
|
||||
onUnmounted(() => clearTimeout(debounceTimer))
|
||||
</script>
|
||||
|
||||
@@ -59,7 +63,7 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
:src="activeSrc"
|
||||
:alt="book.title ?? ''"
|
||||
class="w-full h-full object-cover"
|
||||
@error="(e) => ((e.target as HTMLImageElement).style.visibility = 'hidden')"
|
||||
@error="hideOnError"
|
||||
@load="(e) => ((e.target as HTMLImageElement).style.visibility = 'visible')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Star } from 'lucide-vue-next'
|
||||
import { Search, Star } from 'lucide-vue-next'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import ChipInput from '@/components/ui/ChipInput.vue'
|
||||
import CoverEditorPanel from './CoverEditorPanel.vue'
|
||||
import MetadataSearchDrawer from './MetadataSearchDrawer.vue'
|
||||
import type { MetadataPatch } from '../../../composables/useMetadataDiff'
|
||||
import { useMetadataEditor } from '../../../composables/useMetadataEditor'
|
||||
import { useAuthorSearch } from '../../../composables/useAuthorSearch'
|
||||
import { useTagSearch } from '../../../composables/useTagSearch'
|
||||
@@ -15,31 +17,25 @@ const { form, saving, error, isDirty, load, reset, save } = useMetadataEditor()
|
||||
const { search: searchAuthors } = useAuthorSearch()
|
||||
const { search: searchTags } = useTagSearch()
|
||||
|
||||
const coverPanel = ref<InstanceType<typeof CoverEditorPanel> | null>(null)
|
||||
const searchOpen = ref(false)
|
||||
|
||||
function setIntField(field: 'publishedYear' | 'pageCount', e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
if (val === '') {
|
||||
form[field] = null
|
||||
return
|
||||
}
|
||||
if (val === '') { form[field] = null; return }
|
||||
const n = parseInt(val, 10)
|
||||
form[field] = isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
function setFloatField(field: 'seriesIndex', e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
if (val === '') {
|
||||
form[field] = null
|
||||
return
|
||||
}
|
||||
if (val === '') { form[field] = null; return }
|
||||
const n = parseFloat(val)
|
||||
form[field] = isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
onMounted(() => load(props.book))
|
||||
watch(
|
||||
() => props.book.id,
|
||||
() => load(props.book),
|
||||
)
|
||||
watch(() => props.book.id, () => load(props.book))
|
||||
|
||||
async function submit() {
|
||||
const updated = await save(props.book.id)
|
||||
@@ -52,13 +48,18 @@ const displayRating = computed(() => hoverRating.value ?? form.rating)
|
||||
function setRating(star: number) {
|
||||
form.rating = form.rating === star ? null : star
|
||||
}
|
||||
|
||||
function handleApply({ formPatch, coverUrl }: { formPatch: MetadataPatch; coverUrl?: string }) {
|
||||
Object.assign(form, formPatch)
|
||||
if (coverUrl) coverPanel.value?.setUrl(coverUrl)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<!-- Left: Cover panel -->
|
||||
<div class="w-full lg:w-52 lg:shrink-0 lg:sticky lg:top-6">
|
||||
<CoverEditorPanel :book="props.book" @cover-changed="(src) => emit('coverChanged', src)" />
|
||||
<CoverEditorPanel ref="coverPanel" :book="props.book" @cover-changed="(src) => emit('coverChanged', src)" />
|
||||
</div>
|
||||
|
||||
<!-- Right: Form -->
|
||||
@@ -68,6 +69,13 @@ function setRating(star: number) {
|
||||
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
||||
<span v-else />
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-input bg-background text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="searchOpen = true"
|
||||
>
|
||||
<Search class="size-3.5" />
|
||||
Search online
|
||||
</button>
|
||||
<button
|
||||
class="h-8 px-4 rounded-lg border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-40"
|
||||
:disabled="!isDirty || saving"
|
||||
@@ -245,4 +253,11 @@ function setRating(star: number) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MetadataSearchDrawer
|
||||
v-if="searchOpen"
|
||||
:book="props.book"
|
||||
@close="searchOpen = false"
|
||||
@apply="handleApply"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ArrowLeft } from 'lucide-vue-next'
|
||||
import type { BookDetail, MetadataCandidate, MetadataProviderInfo } from '@projectx/types'
|
||||
import { useMetadataDiff, type MetadataPatch } from '../../../composables/useMetadataDiff'
|
||||
import { getProviderLabel } from '../../../lib/metadata-fetch'
|
||||
import MetadataDiffRow from './MetadataDiffRow.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
book: BookDetail
|
||||
candidate: MetadataCandidate
|
||||
providers: MetadataProviderInfo[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
apply: [{ formPatch: MetadataPatch; coverUrl?: string }]
|
||||
}>()
|
||||
|
||||
const providerLabel = computed(() => getProviderLabel(props.candidate.provider, props.providers))
|
||||
|
||||
const { fields, toggleField, copyAll, copyMissing, buildPatch, hasCopied } = useMetadataDiff(
|
||||
props.book,
|
||||
props.candidate,
|
||||
)
|
||||
|
||||
function apply() {
|
||||
emit('apply', buildPatch())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
|
||||
<button
|
||||
class="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
<ArrowLeft class="size-4" />
|
||||
Results
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ candidate.title }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ providerLabel }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button
|
||||
class="h-7 px-3 rounded-md border border-input text-xs hover:bg-muted transition-colors"
|
||||
@click="copyMissing"
|
||||
>
|
||||
Copy missing
|
||||
</button>
|
||||
<button
|
||||
class="h-7 px-3 rounded-md border border-input text-xs hover:bg-muted transition-colors"
|
||||
@click="copyAll"
|
||||
>
|
||||
Copy all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column labels -->
|
||||
<div class="grid grid-cols-[1fr_40px_1fr] gap-2 px-4 py-2 bg-muted/30 border-b border-border shrink-0">
|
||||
<p class="text-xs font-medium text-muted-foreground">Current</p>
|
||||
<div />
|
||||
<p class="text-xs font-medium text-muted-foreground">{{ providerLabel }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Diff rows -->
|
||||
<div class="flex-1 overflow-y-auto px-4">
|
||||
<MetadataDiffRow
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
:field="field"
|
||||
@toggle="toggleField"
|
||||
/>
|
||||
<p v-if="fields.length === 0" class="py-8 text-center text-sm text-muted-foreground">
|
||||
No metadata available from this source.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-border shrink-0">
|
||||
<button
|
||||
class="h-8 px-4 rounded-lg border border-input bg-background text-sm hover:bg-muted transition-colors"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-40"
|
||||
:disabled="!hasCopied"
|
||||
@click="apply"
|
||||
>
|
||||
Apply to form
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeft, Check, RotateCcw } from 'lucide-vue-next'
|
||||
import type { DiffField, DiffFieldKey } from '../../../composables/useMetadataDiff'
|
||||
import { hideOnError } from '../../../lib/metadata-fetch'
|
||||
|
||||
defineProps<{ field: DiffField }>()
|
||||
defineEmits<{ toggle: [DiffFieldKey] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Cover row -->
|
||||
<div v-if="field.isCover" class="grid grid-cols-[1fr_40px_1fr] gap-2 py-3 border-b border-border/50 items-center">
|
||||
<div class="space-y-1">
|
||||
<p class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ field.label }}</p>
|
||||
<div
|
||||
class="w-16 rounded overflow-hidden bg-muted transition-colors"
|
||||
:class="field.isCopied ? 'ring-2 ring-primary' : ''"
|
||||
style="aspect-ratio: 2/3"
|
||||
>
|
||||
<img
|
||||
v-if="field.isCopied"
|
||||
:src="field.candidateDisplay"
|
||||
class="w-full h-full object-cover"
|
||||
@error="hideOnError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="size-7 rounded-full flex items-center justify-center transition-colors"
|
||||
:class="
|
||||
field.isCopied
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/80'
|
||||
: 'bg-muted hover:bg-muted-foreground/20 text-muted-foreground'
|
||||
"
|
||||
@click="$emit('toggle', field.key)"
|
||||
>
|
||||
<RotateCcw v-if="field.isCopied" class="size-3" />
|
||||
<ArrowLeft v-else class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<p class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground opacity-0">cover</p>
|
||||
<div class="w-16 rounded overflow-hidden bg-muted" style="aspect-ratio: 2/3">
|
||||
<img
|
||||
:src="field.candidateDisplay"
|
||||
class="w-full h-full object-cover"
|
||||
@error="hideOnError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text row -->
|
||||
<div v-else class="grid grid-cols-[1fr_40px_1fr] gap-2 py-2.5 border-b border-border/50 items-start">
|
||||
<!-- Current (left) -->
|
||||
<div
|
||||
class="min-w-0 rounded px-2 py-1.5 text-sm transition-colors"
|
||||
:class="field.isCopied ? 'bg-primary/8 text-foreground' : 'text-foreground'"
|
||||
>
|
||||
<p class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mb-1">{{ field.label }}</p>
|
||||
<p class="break-words leading-snug" :class="!field.currentDisplay ? 'text-muted-foreground/40 italic' : ''">
|
||||
{{ field.currentDisplay || 'empty' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Toggle button -->
|
||||
<div class="flex justify-center pt-6">
|
||||
<button
|
||||
v-if="field.hasDiff"
|
||||
class="size-7 rounded-full flex items-center justify-center transition-colors"
|
||||
:class="
|
||||
field.isCopied
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/80'
|
||||
: 'bg-muted hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="$emit('toggle', field.key)"
|
||||
>
|
||||
<RotateCcw v-if="field.isCopied" class="size-3" />
|
||||
<ArrowLeft v-else class="size-3" />
|
||||
</button>
|
||||
<div v-else class="size-7 flex items-center justify-center text-muted-foreground/40">
|
||||
<Check class="size-3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Candidate (right) -->
|
||||
<div class="min-w-0 rounded px-2 py-1.5 text-sm" :class="!field.hasDiff ? 'opacity-40' : ''">
|
||||
<p class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mb-1 opacity-0">
|
||||
{{ field.label }}
|
||||
</p>
|
||||
<p class="break-words leading-snug text-muted-foreground">{{ field.candidateDisplay }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { MetadataCandidate, MetadataProviderInfo } from '@projectx/types'
|
||||
import { getProviderLabel, hideOnError } from '../../../lib/metadata-fetch'
|
||||
|
||||
const props = defineProps<{
|
||||
candidate: MetadataCandidate
|
||||
providers: MetadataProviderInfo[]
|
||||
}>()
|
||||
|
||||
defineEmits<{ select: [MetadataCandidate] }>()
|
||||
|
||||
const providerLabel = computed(() => getProviderLabel(props.candidate.provider, props.providers))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="w-full flex gap-3 p-3 rounded-lg border border-transparent hover:border-border hover:bg-muted/50 text-left transition-colors group"
|
||||
@click="$emit('select', candidate)"
|
||||
>
|
||||
<!-- Cover thumbnail -->
|
||||
<div class="shrink-0 w-10 rounded overflow-hidden bg-muted" style="aspect-ratio: 2/3">
|
||||
<img
|
||||
v-if="candidate.coverUrl"
|
||||
:src="candidate.coverUrl"
|
||||
:alt="candidate.title"
|
||||
class="w-full h-full object-cover"
|
||||
@error="hideOnError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0 space-y-0.5">
|
||||
<p class="text-sm font-medium leading-snug truncate group-hover:text-foreground">{{ candidate.title }}</p>
|
||||
<p v-if="candidate.authors?.length" class="text-xs text-muted-foreground truncate">
|
||||
{{ candidate.authors.join(', ') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 pt-0.5">
|
||||
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{{ providerLabel }}
|
||||
</span>
|
||||
<span v-if="candidate.publishedYear" class="text-[10px] text-muted-foreground">
|
||||
{{ candidate.publishedYear }}
|
||||
</span>
|
||||
<span v-if="candidate.pageCount" class="text-[10px] text-muted-foreground">
|
||||
{{ candidate.pageCount }}p
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import type { BookDetail, MetadataCandidate } from '@projectx/types'
|
||||
import { useMetadataSearch } from '../../../composables/useMetadataSearch'
|
||||
import type { MetadataPatch } from '../../../composables/useMetadataDiff'
|
||||
import MetadataSearchPanel from './MetadataSearchPanel.vue'
|
||||
import MetadataDiffPanel from './MetadataDiffPanel.vue'
|
||||
|
||||
const props = defineProps<{ book: BookDetail }>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
apply: [{ formPatch: MetadataPatch; coverUrl?: string }]
|
||||
}>()
|
||||
|
||||
const {
|
||||
filteredResults,
|
||||
providerCounts,
|
||||
isStreaming,
|
||||
hasSearched,
|
||||
providers,
|
||||
selectedProviders,
|
||||
loadProviders,
|
||||
search,
|
||||
toggleProvider,
|
||||
clearProviderFilter,
|
||||
} = useMetadataSearch()
|
||||
|
||||
const view = ref<'search' | 'diff'>('search')
|
||||
const selectedCandidate = ref<MetadataCandidate | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
})
|
||||
|
||||
function handleSearch(params: { title: string; author: string; isbn: string }) {
|
||||
search({ ...params, bookId: props.book.id })
|
||||
}
|
||||
|
||||
function selectCandidate(candidate: MetadataCandidate) {
|
||||
selectedCandidate.value = candidate
|
||||
view.value = 'diff'
|
||||
}
|
||||
|
||||
function backToSearch() {
|
||||
view.value = 'search'
|
||||
selectedCandidate.value = null
|
||||
}
|
||||
|
||||
function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) {
|
||||
emit('apply', patch)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 z-50 flex">
|
||||
<!-- Backdrop -->
|
||||
<div class="flex-1 bg-black/40 backdrop-blur-sm" @click="$emit('close')" />
|
||||
|
||||
<!-- Drawer panel -->
|
||||
<div class="relative flex flex-col w-3/4 max-w-4xl h-full bg-background border-l border-border shadow-2xl">
|
||||
<!-- Close -->
|
||||
<button
|
||||
class="absolute top-3 right-3 z-10 size-7 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</button>
|
||||
|
||||
<!-- Title bar -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0 pr-12">
|
||||
<p class="text-sm font-semibold">Search Metadata</p>
|
||||
<p class="text-xs text-muted-foreground truncate">{{ book.title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-h-0">
|
||||
<MetadataSearchPanel
|
||||
v-if="view === 'search'"
|
||||
:book="book"
|
||||
:providers="providers"
|
||||
:filtered-results="filteredResults"
|
||||
:provider-counts="providerCounts"
|
||||
:selected-providers="selectedProviders"
|
||||
:is-streaming="isStreaming"
|
||||
:has-searched="hasSearched"
|
||||
@search="handleSearch"
|
||||
@toggle-provider="toggleProvider"
|
||||
@clear-filter="clearProviderFilter"
|
||||
@select="selectCandidate"
|
||||
/>
|
||||
|
||||
<MetadataDiffPanel
|
||||
v-else-if="view === 'diff' && selectedCandidate"
|
||||
:book="book"
|
||||
:candidate="selectedCandidate"
|
||||
:providers="providers"
|
||||
@back="backToSearch"
|
||||
@apply="handleApply"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from 'vue'
|
||||
import { Search } from 'lucide-vue-next'
|
||||
import type { BookDetail, MetadataCandidate, MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
|
||||
import MetadataResultCard from './MetadataResultCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
book: BookDetail
|
||||
providers: MetadataProviderInfo[]
|
||||
filteredResults: MetadataCandidate[]
|
||||
providerCounts: Partial<Record<MetadataProviderKey, number>>
|
||||
selectedProviders: MetadataProviderKey[]
|
||||
isStreaming: boolean
|
||||
hasSearched: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [{ title: string; author: string; isbn: string }]
|
||||
toggleProvider: [MetadataProviderKey]
|
||||
clearFilter: []
|
||||
select: [MetadataCandidate]
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
title: props.book.title ?? '',
|
||||
author: props.book.authors[0]?.name ?? '',
|
||||
isbn: props.book.isbn13 ?? props.book.isbn10 ?? '',
|
||||
})
|
||||
|
||||
const canSearch = computed(() => !!(form.title.trim() || form.isbn.trim()))
|
||||
|
||||
function runSearch() {
|
||||
if (canSearch.value) emit('search', { ...form })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Search form -->
|
||||
<div class="px-4 pt-4 pb-3 space-y-2 shrink-0">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="flex-1 h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow"
|
||||
placeholder="Title"
|
||||
@keydown.enter="runSearch"
|
||||
/>
|
||||
<input
|
||||
v-model="form.author"
|
||||
class="flex-1 h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow"
|
||||
placeholder="Author"
|
||||
@keydown.enter="runSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.isbn"
|
||||
class="flex-1 h-8 rounded-lg border border-input bg-background px-3 text-sm font-mono outline-none focus:ring-1 focus:ring-ring transition-shadow"
|
||||
placeholder="ISBN"
|
||||
@keydown.enter="runSearch"
|
||||
/>
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-40"
|
||||
:disabled="!canSearch || isStreaming"
|
||||
@click="runSearch"
|
||||
>
|
||||
<Search class="size-3.5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider filter pills -->
|
||||
<div v-if="providers.length" class="flex items-center gap-1.5 px-4 pb-3 flex-wrap shrink-0">
|
||||
<button
|
||||
class="h-6 px-2.5 rounded-full text-xs font-medium transition-colors"
|
||||
:class="
|
||||
!selectedProviders.length
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="$emit('clearFilter')"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
v-for="p in providers"
|
||||
:key="p.key"
|
||||
class="h-6 px-2.5 rounded-full text-xs font-medium transition-colors flex items-center gap-1"
|
||||
:class="
|
||||
selectedProviders.includes(p.key)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="$emit('toggleProvider', p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
<span v-if="providerCounts[p.key]" class="opacity-70">{{ providerCounts[p.key] }}</span>
|
||||
<span v-else-if="isStreaming" class="size-1.5 rounded-full bg-current animate-pulse" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<div v-if="isStreaming && !filteredResults.length" class="py-8 flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<div class="size-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
<p class="text-sm">Searching...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isStreaming && !filteredResults.length && hasSearched" class="py-8 text-center text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-0.5">
|
||||
<MetadataResultCard
|
||||
v-for="(candidate, i) in filteredResults"
|
||||
:key="`${candidate.provider}-${candidate.providerId}-${i}`"
|
||||
:candidate="candidate"
|
||||
:providers="providers"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,138 @@
|
||||
import { computed, reactive } from 'vue'
|
||||
import type { BookDetail, MetadataCandidate } from '@projectx/types'
|
||||
|
||||
export type DiffFieldKey =
|
||||
| 'title'
|
||||
| 'subtitle'
|
||||
| 'authors'
|
||||
| 'description'
|
||||
| 'publisher'
|
||||
| 'publishedYear'
|
||||
| 'language'
|
||||
| 'pageCount'
|
||||
| 'seriesName'
|
||||
| 'seriesIndex'
|
||||
| 'isbn13'
|
||||
| 'isbn10'
|
||||
| 'tags'
|
||||
| 'coverUrl'
|
||||
|
||||
export interface DiffField {
|
||||
key: DiffFieldKey
|
||||
label: string
|
||||
bookValue: string
|
||||
currentDisplay: string
|
||||
candidateDisplay: string
|
||||
hasDiff: boolean
|
||||
isCopied: boolean
|
||||
isCover: boolean
|
||||
}
|
||||
|
||||
export interface MetadataPatch {
|
||||
title?: string | null
|
||||
subtitle?: string | null
|
||||
description?: string | null
|
||||
publisher?: string | null
|
||||
publishedYear?: number | null
|
||||
language?: string | null
|
||||
pageCount?: number | null
|
||||
seriesName?: string | null
|
||||
seriesIndex?: number | null
|
||||
isbn10?: string | null
|
||||
isbn13?: string | null
|
||||
authors?: string[]
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const FIELD_DEFS: { key: DiffFieldKey; label: string }[] = [
|
||||
{ key: 'title', label: 'Title' },
|
||||
{ key: 'subtitle', label: 'Subtitle' },
|
||||
{ key: 'authors', label: 'Authors' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'publisher', label: 'Publisher' },
|
||||
{ key: 'publishedYear', label: 'Published Year' },
|
||||
{ key: 'language', label: 'Language' },
|
||||
{ key: 'pageCount', label: 'Page Count' },
|
||||
{ key: 'seriesName', label: 'Series' },
|
||||
{ key: 'seriesIndex', label: 'Series Index' },
|
||||
{ key: 'isbn13', label: 'ISBN-13' },
|
||||
{ key: 'isbn10', label: 'ISBN-10' },
|
||||
{ key: 'tags', label: 'Tags' },
|
||||
{ key: 'coverUrl', label: 'Cover' },
|
||||
]
|
||||
|
||||
export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate) {
|
||||
const copiedFields = reactive(new Set<DiffFieldKey>())
|
||||
|
||||
function getBookValue(key: DiffFieldKey): string {
|
||||
if (key === 'authors') return book.authors.map((a) => a.name).join(', ')
|
||||
if (key === 'tags') return book.tags.join(', ')
|
||||
if (key === 'coverUrl') return ''
|
||||
const val = book[key as keyof BookDetail]
|
||||
return val != null ? String(val) : ''
|
||||
}
|
||||
|
||||
function getCandidateValue(key: DiffFieldKey): string {
|
||||
if (key === 'coverUrl') return candidate.coverUrl ?? ''
|
||||
if (key === 'authors') return (candidate.authors ?? []).join(', ')
|
||||
if (key === 'tags') return (candidate.tags ?? []).join(', ')
|
||||
const val = candidate[key as keyof MetadataCandidate]
|
||||
return val != null ? String(val) : ''
|
||||
}
|
||||
|
||||
const fields = computed<DiffField[]>(() =>
|
||||
FIELD_DEFS.flatMap((def) => {
|
||||
const candidateVal = getCandidateValue(def.key)
|
||||
if (!candidateVal) return []
|
||||
const bookVal = getBookValue(def.key)
|
||||
const isCopied = copiedFields.has(def.key)
|
||||
return [{
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
bookValue: bookVal,
|
||||
currentDisplay: isCopied ? candidateVal : bookVal,
|
||||
candidateDisplay: candidateVal,
|
||||
hasDiff: bookVal !== candidateVal,
|
||||
isCopied,
|
||||
isCover: def.key === 'coverUrl',
|
||||
}]
|
||||
}),
|
||||
)
|
||||
|
||||
function toggleField(key: DiffFieldKey) {
|
||||
if (copiedFields.has(key)) copiedFields.delete(key)
|
||||
else copiedFields.add(key)
|
||||
}
|
||||
|
||||
function copyAll() {
|
||||
for (const f of fields.value) copiedFields.add(f.key)
|
||||
}
|
||||
|
||||
function copyMissing() {
|
||||
for (const f of fields.value) {
|
||||
if (!f.bookValue) copiedFields.add(f.key)
|
||||
}
|
||||
}
|
||||
|
||||
function buildPatch(): { formPatch: MetadataPatch; coverUrl?: string } {
|
||||
const formPatch: MetadataPatch = {}
|
||||
let coverUrl: string | undefined
|
||||
|
||||
for (const key of copiedFields) {
|
||||
if (key === 'coverUrl') { coverUrl = candidate.coverUrl; continue }
|
||||
if (key === 'authors') { formPatch.authors = candidate.authors ?? []; continue }
|
||||
if (key === 'tags') { formPatch.tags = candidate.tags ?? []; continue }
|
||||
if (key === 'publishedYear') { formPatch.publishedYear = candidate.publishedYear ?? null; continue }
|
||||
if (key === 'pageCount') { formPatch.pageCount = candidate.pageCount ?? null; continue }
|
||||
if (key === 'seriesIndex') { formPatch.seriesIndex = candidate.seriesIndex ?? null; continue }
|
||||
const val = candidate[key as keyof MetadataCandidate]
|
||||
;(formPatch as Record<string, unknown>)[key] = val != null ? String(val) : null
|
||||
}
|
||||
|
||||
return { formPatch, coverUrl }
|
||||
}
|
||||
|
||||
const hasCopied = computed(() => copiedFields.size > 0)
|
||||
|
||||
return { fields, copiedFields, toggleField, copyAll, copyMissing, buildPatch, hasCopied }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { computed, onUnmounted, reactive, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { MetadataCandidate, MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
|
||||
|
||||
export interface SearchParams {
|
||||
title?: string
|
||||
author?: string
|
||||
isbn?: string
|
||||
bookId?: number
|
||||
}
|
||||
|
||||
export function useMetadataSearch() {
|
||||
const results = ref<MetadataCandidate[]>([])
|
||||
const providerCounts = reactive<Partial<Record<MetadataProviderKey, number>>>({})
|
||||
const isStreaming = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
const providers = ref<MetadataProviderInfo[]>([])
|
||||
const selectedProviders = ref<MetadataProviderKey[]>([])
|
||||
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
async function loadProviders() {
|
||||
const res = await api('/api/metadata-fetch/providers')
|
||||
if (res.ok) providers.value = (await res.json()) as MetadataProviderInfo[]
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
abortController?.abort()
|
||||
abortController = null
|
||||
isStreaming.value = false
|
||||
}
|
||||
|
||||
onUnmounted(cancel)
|
||||
|
||||
async function search(params: SearchParams) {
|
||||
cancel()
|
||||
results.value = []
|
||||
for (const k of Object.keys(providerCounts)) delete providerCounts[k as MetadataProviderKey]
|
||||
hasSearched.value = true
|
||||
isStreaming.value = true
|
||||
abortController = new AbortController()
|
||||
|
||||
const query = new URLSearchParams()
|
||||
if (params.title) query.set('title', params.title)
|
||||
if (params.author) query.set('author', params.author)
|
||||
if (params.isbn) query.set('isbn', params.isbn)
|
||||
if (params.bookId != null) query.set('bookId', String(params.bookId))
|
||||
if (selectedProviders.value.length) query.set('providers', selectedProviders.value.join(','))
|
||||
|
||||
try {
|
||||
const res = await api(`/api/metadata-fetch/stream?${query}`, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
if (!res.ok || !res.body) return
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const events = buffer.split('\n\n')
|
||||
buffer = events.pop() ?? ''
|
||||
for (const event of events) {
|
||||
const line = event.split('\n').find((l) => l.startsWith('data:'))
|
||||
if (!line) continue
|
||||
try {
|
||||
const candidate = JSON.parse(line.slice(5).trim()) as MetadataCandidate
|
||||
results.value.push(candidate)
|
||||
providerCounts[candidate.provider] = (providerCounts[candidate.provider] ?? 0) + 1
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') return
|
||||
} finally {
|
||||
isStreaming.value = false
|
||||
abortController = null
|
||||
}
|
||||
}
|
||||
|
||||
const filteredResults = computed(() =>
|
||||
selectedProviders.value.length
|
||||
? results.value.filter((r) => selectedProviders.value.includes(r.provider))
|
||||
: results.value,
|
||||
)
|
||||
|
||||
function toggleProvider(key: MetadataProviderKey) {
|
||||
const idx = selectedProviders.value.indexOf(key)
|
||||
if (idx === -1) selectedProviders.value.push(key)
|
||||
else selectedProviders.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function clearProviderFilter() {
|
||||
selectedProviders.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
filteredResults,
|
||||
providerCounts,
|
||||
isStreaming,
|
||||
hasSearched,
|
||||
providers,
|
||||
selectedProviders,
|
||||
loadProviders,
|
||||
search,
|
||||
toggleProvider,
|
||||
clearProviderFilter,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
|
||||
|
||||
export function getProviderLabel(provider: MetadataProviderKey, providers: MetadataProviderInfo[]): string {
|
||||
return providers.find((p) => p.key === provider)?.label ?? provider
|
||||
}
|
||||
|
||||
export function hideOnError(e: Event): void {
|
||||
(e.target as HTMLImageElement).style.visibility = 'hidden'
|
||||
}
|
||||
Reference in New Issue
Block a user