feat(metadata): add search drawer with Google Books and Goodreads

This commit is contained in:
neon
2026-02-19 02:03:33 -06:00
parent 1dd6381325
commit d4850dc6d7
16 changed files with 1076 additions and 32 deletions
@@ -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'
}