feat: wire provider IDs full stack and polish metadata search UI

- Add providerIds (google/goodreads/amazon/hardcover/openLibrary) to BookDetail type, DTO, service save/load
- Expose provider ID fields in edit metadata form with editable inputs
- Auto-save provider ID on metadata apply from search drawer
- Polished metadata search: skeleton loaders, provider-colored pills/badges, result cards, diff panel
- Fix: provider ID only injected in patch when user has selected fields
This commit is contained in:
neon
2026-02-19 03:37:14 -06:00
parent ed3c36e35d
commit 1c97403379
19 changed files with 447 additions and 236 deletions
@@ -50,7 +50,7 @@ async function handleRevert() {
const lightboxOpen = ref(false)
defineExpose({ setUrl })
defineExpose({ setUrl, hasPending, confirm })
onUnmounted(() => clearTimeout(debounceTimer))
</script>
@@ -20,6 +20,14 @@ const { search: searchTags } = useTagSearch()
const coverPanel = ref<InstanceType<typeof CoverEditorPanel> | null>(null)
const searchOpen = ref(false)
const providerIdFields = [
{ field: 'googleBooksId' as const, label: 'Google Books' },
{ field: 'goodreadsId' as const, label: 'Goodreads' },
{ field: 'amazonId' as const, label: 'Amazon' },
{ field: 'hardcoverId' as const, label: 'Hardcover' },
{ field: 'openLibraryId' as const, label: 'OpenLibrary' },
]
function setIntField(field: 'publishedYear' | 'pageCount', e: Event) {
const val = (e.target as HTMLInputElement).value
if (val === '') { form[field] = null; return }
@@ -38,6 +46,10 @@ onMounted(() => load(props.book))
watch(() => props.book.id, () => load(props.book))
async function submit() {
if (coverPanel.value?.hasPending) {
const ok = await coverPanel.value.confirm()
if (ok) emit('coverChanged', 'custom')
}
const updated = await save(props.book.id)
if (updated) emit('saved', updated)
}
@@ -215,6 +227,21 @@ function handleApply({ formPatch, coverUrl }: { formPatch: MetadataPatch; coverU
</div>
</div>
<!-- Provider IDs -->
<div class="space-y-2">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider IDs</label>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div v-for="{ field, label } in providerIdFields" :key="field" class="flex items-center gap-2">
<span class="text-xs text-muted-foreground w-24 shrink-0">{{ label }}</span>
<input
v-model="form[field]"
class="flex-1 h-8 rounded-lg border border-input bg-background px-2.5 text-xs font-mono outline-none focus:ring-1 focus:ring-ring transition-shadow"
:placeholder="label + ' ID'"
/>
</div>
</div>
</div>
<!-- Rating -->
<div class="space-y-1.5">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Rating</label>
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowLeft } from 'lucide-vue-next'
import { ArrowLeft, CopyCheck, Copy, CheckCheck } 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 { getProviderLabel, providerBadgeStyle, getProviderColor } from '../../../lib/metadata-fetch'
import MetadataDiffRow from './MetadataDiffRow.vue'
const props = defineProps<{
@@ -32,39 +32,54 @@ function apply() {
<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">
<div class="flex items-start gap-3 px-4 py-3 border-b border-border shrink-0 bg-card/50">
<button
class="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
class="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors mt-0.5 shrink-0 group"
@click="$emit('back')"
>
<ArrowLeft class="size-4" />
<ArrowLeft class="size-4 transition-transform group-hover:-translate-x-0.5" />
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>
<p class="text-sm font-semibold truncate leading-snug">{{ candidate.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5">
<span
class="text-[10px] font-semibold px-2 py-0.5 rounded-full"
:style="providerBadgeStyle(candidate.provider)"
>
{{ providerLabel }}
</span>
</div>
</div>
<div class="flex gap-2 shrink-0">
<div class="flex gap-1.5 shrink-0">
<button
class="h-7 px-3 rounded-md border border-input text-xs hover:bg-muted transition-colors"
class="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-border text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-all active:scale-95"
@click="copyMissing"
>
Copy missing
<Copy class="size-3" />
Missing
</button>
<button
class="h-7 px-3 rounded-md border border-input text-xs hover:bg-muted transition-colors"
class="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-border text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-all active:scale-95"
@click="copyAll"
>
Copy all
<CopyCheck class="size-3" />
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 class="grid grid-cols-[1fr_40px_1fr] gap-2 px-4 py-2 border-b border-border shrink-0">
<div class="flex items-center gap-1.5">
<div class="size-1.5 rounded-full bg-muted-foreground/40" />
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Current</p>
</div>
<div />
<p class="text-xs font-medium text-muted-foreground">{{ providerLabel }}</p>
<div class="flex items-center gap-1.5">
<div class="size-1.5 rounded-full" :style="{ backgroundColor: getProviderColor(candidate.provider) }" />
<p class="text-xs font-semibold uppercase tracking-wider" :style="{ color: getProviderColor(candidate.provider) }">{{ providerLabel }}</p>
</div>
</div>
<!-- Diff rows -->
@@ -81,20 +96,28 @@ function apply() {
</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 class="flex items-center justify-between gap-2 px-4 py-3 border-t border-border shrink-0 bg-card/30">
<p v-if="hasCopied" class="text-xs text-muted-foreground flex items-center gap-1.5">
<CheckCheck class="size-3.5 text-primary" />
Fields selected for import
</p>
<div v-else class="text-xs text-muted-foreground">Select fields to apply</div>
<div class="flex gap-2">
<button
class="h-8 px-3 rounded-lg border border-border bg-background text-sm hover:bg-muted transition-all active:scale-95"
@click="$emit('back')"
>
Cancel
</button>
<button
class="relative h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all disabled:opacity-40 hover:opacity-90 active:scale-95 overflow-hidden group"
:disabled="!hasCopied"
@click="apply"
>
<span class="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity" />
Apply to form
</button>
</div>
</div>
</div>
</template>
@@ -1,97 +1,147 @@
<script setup lang="ts">
import { ArrowLeft, Check, RotateCcw } from 'lucide-vue-next'
import { ref } from 'vue'
import { ArrowLeft, RotateCcw, CheckCircle2, X, ZoomIn } from 'lucide-vue-next'
import type { DiffField, DiffFieldKey } from '../../../composables/useMetadataDiff'
import { hideOnError } from '../../../lib/metadata-fetch'
defineProps<{ field: DiffField }>()
defineEmits<{ toggle: [DiffFieldKey] }>()
const lightboxSrc = ref<string | null>(null)
</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 v-if="field.isCover" class="py-3.5 border-b border-border/40">
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-2.5">{{ field.label }}</p>
<div class="grid grid-cols-[1fr_44px_1fr] gap-2 items-center">
<!-- Current cover -->
<div
class="w-16 rounded overflow-hidden bg-muted transition-colors"
:class="field.isCopied ? 'ring-2 ring-primary' : ''"
class="w-16 rounded-lg overflow-hidden bg-muted transition-all duration-300 shadow-sm ring-1 relative group"
:class="field.isCopied ? 'ring-primary ring-2 cursor-zoom-in' : 'ring-border opacity-50'"
style="aspect-ratio: 2/3"
@click="field.isCopied ? lightboxSrc = field.candidateDisplay : null"
>
<img
v-if="field.isCopied"
:src="field.candidateDisplay"
class="w-full h-full object-cover"
@error="hideOnError"
/>
<img v-if="field.isCopied" :src="field.candidateDisplay" alt="Preview" class="w-full h-full object-cover" @error="hideOnError" />
<div v-else class="w-full h-full bg-linear-to-br from-muted to-muted-foreground/10" />
<div v-if="field.isCopied" class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<ZoomIn class="size-4 text-white" />
</div>
</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)"
<!-- Toggle -->
<div class="flex justify-center">
<button
class="size-8 rounded-full flex items-center justify-center transition-all active:scale-90 shadow-sm"
:class="field.isCopied ? 'bg-primary text-primary-foreground shadow-primary/30' : 'bg-card border border-border text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5'"
@click="$emit('toggle', field.key)"
>
<RotateCcw v-if="field.isCopied" class="size-3.5" />
<ArrowLeft v-else class="size-3.5" />
</button>
</div>
<!-- New cover -->
<div
class="w-16 rounded-lg overflow-hidden bg-muted shadow-sm ring-1 transition-all duration-300 relative group cursor-zoom-in"
:class="field.isCopied ? 'ring-primary ring-2' : 'ring-border'"
style="aspect-ratio: 2/3"
@click="lightboxSrc = field.candidateDisplay"
>
<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"
/>
<img :src="field.candidateDisplay" alt="New cover" class="w-full h-full object-cover" @error="hideOnError" />
<div class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<ZoomIn class="size-4 text-white" />
</div>
</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>
<div v-else class="py-2.5 border-b border-border/40">
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-1.5">{{ field.label }}</p>
<!-- 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"
<div class="flex flex-col gap-2 sm:grid sm:grid-cols-[1fr_44px_1fr] sm:gap-1.5 sm:items-stretch">
<!-- Current value: framed card to signal it's the active record -->
<div
class="min-w-0 rounded-lg px-3 py-2 transition-all duration-200"
: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'
!field.hasDiff
? 'bg-muted/30 opacity-50'
: field.isCopied
? 'bg-muted/30 opacity-40'
: 'bg-background ring-1 ring-border'
"
@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" />
<p class="text-[10px] font-medium text-muted-foreground mb-0.5 sm:hidden">Current</p>
<p
class="wrap-break-word leading-snug text-sm w-full"
:class="!field.currentDisplay ? 'text-muted-foreground/40 italic' : 'text-foreground'"
>
{{ field.currentDisplay || 'empty' }}
</p>
</div>
<!-- Toggle button -->
<div class="flex items-center justify-center">
<button
v-if="field.hasDiff"
class="size-8 rounded-full flex items-center justify-center transition-all active:scale-90 shadow-sm shrink-0"
:class="field.isCopied ? 'bg-primary text-primary-foreground shadow-primary/30' : 'bg-card border border-border text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5'"
@click="$emit('toggle', field.key)"
>
<RotateCcw v-if="field.isCopied" class="size-3.5" />
<ArrowLeft v-else class="size-3.5" />
</button>
<div v-else class="size-8 flex items-center justify-center">
<CheckCircle2 class="size-3.5 text-muted-foreground/25" />
</div>
</div>
<!-- New value: muted by default, highlights when selected -->
<div
class="min-w-0 rounded-lg px-3 py-2 transition-all duration-200"
:class="
!field.hasDiff
? 'bg-muted/30 opacity-50'
: field.isCopied
? 'bg-primary/8 ring-1 ring-primary/20'
: 'bg-muted/40'
"
>
<p class="text-[10px] font-medium text-muted-foreground mb-0.5 sm:hidden">New</p>
<p
class="wrap-break-word leading-snug text-sm w-full"
:class="field.isCopied ? 'text-primary font-medium' : 'text-muted-foreground'"
>
{{ field.candidateDisplay }}
</p>
</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>
<!-- Lightbox -->
<Teleport to="body">
<div
v-if="lightboxSrc"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm"
@click="lightboxSrc = null"
>
<button
class="absolute top-4 right-4 size-9 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center transition-colors"
@click="lightboxSrc = null"
>
<X class="size-5" />
</button>
<img
:src="lightboxSrc"
alt="Cover preview"
class="max-h-[85vh] max-w-[85vw] rounded-xl shadow-2xl object-contain"
@click.stop
@error="hideOnError"
/>
</div>
</Teleport>
</template>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BookOpen, ChevronRight } from 'lucide-vue-next'
import type { MetadataCandidate, MetadataProviderInfo } from '@projectx/types'
import { getProviderLabel, hideOnError } from '../../../lib/metadata-fetch'
import { getProviderLabel, hideOnError, providerBadgeStyle } from '../../../lib/metadata-fetch'
const props = defineProps<{
candidate: MetadataCandidate
@@ -15,37 +16,44 @@ const providerLabel = computed(() => getProviderLabel(props.candidate.provider,
<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"
class="group relative flex flex-row gap-3 p-2.5 rounded-xl border border-border/60 bg-card text-left transition-all hover:border-border hover:shadow-md hover:-translate-y-px active:scale-[0.98] active:shadow-sm overflow-hidden w-full"
@click="$emit('select', candidate)"
>
<!-- Cover thumbnail -->
<div class="shrink-0 w-10 rounded overflow-hidden bg-muted" style="aspect-ratio: 2/3">
<!-- Cover -->
<span class="relative shrink-0 rounded-lg overflow-hidden bg-muted block shadow-sm" style="width: 64px; aspect-ratio: 2/3">
<img
v-if="candidate.coverUrl"
:src="candidate.coverUrl"
:alt="candidate.title"
class="w-full h-full object-cover"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
@error="hideOnError"
/>
</div>
<span v-else class="absolute inset-0 flex items-center justify-center bg-linear-to-br from-surface-2 to-surface-4">
<BookOpen class="size-5 text-muted-foreground/25" />
</span>
</span>
<!-- 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">
<span class="flex-1 min-w-0 flex flex-col justify-center gap-1 py-0.5">
<span class="text-sm font-semibold leading-snug line-clamp-2 text-foreground">{{ candidate.title }}</span>
<span v-if="candidate.authors?.length" class="text-xs text-muted-foreground truncate block leading-tight">
{{ 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">
</span>
<span class="flex items-center gap-1.5 flex-wrap">
<span
class="inline-flex items-center text-[10px] font-bold px-1.5 py-0.5 rounded-md"
:style="providerBadgeStyle(candidate.provider)"
>
{{ 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>
<span v-if="candidate.publishedYear" class="text-[10px] text-muted-foreground tabular-nums">{{ candidate.publishedYear }}</span>
<span v-if="candidate.pageCount" class="text-[10px] text-muted-foreground tabular-nums">{{ candidate.pageCount }}p</span>
</span>
</span>
<!-- Arrow -->
<span class="shrink-0 self-center opacity-0 group-hover:opacity-100 transition-all -translate-x-1 group-hover:translate-x-0">
<ChevronRight class="size-4 text-muted-foreground" />
</span>
</button>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { X } from 'lucide-vue-next'
import { X, Sparkles, ChevronRight } from 'lucide-vue-next'
import type { BookDetail, MetadataCandidate } from '@projectx/types'
import { useMetadataSearch } from '../../../composables/useMetadataSearch'
import type { MetadataPatch } from '../../../composables/useMetadataDiff'
@@ -56,23 +56,39 @@ function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) {
<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')" />
<!-- Backdrop: hidden on mobile since the panel is full-screen there -->
<div class="hidden sm:block flex-1 bg-black/50 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 -->
<!-- Drawer panel: full-screen on mobile, right-side panel on sm+ -->
<div class="relative flex flex-col w-full sm:w-3/4 sm:max-w-4xl h-full bg-background sm:border-l border-border shadow-2xl overflow-hidden">
<!-- Gradient accent strip -->
<div class="h-px w-full bg-linear-to-r from-transparent via-primary to-transparent shrink-0 opacity-60" />
<!-- Ambient glow behind title -->
<div class="absolute top-0 right-0 w-64 h-32 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
<!-- Close button -->
<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"
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-all hover:scale-110"
@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 class="flex items-center gap-2.5 px-4 py-3 border-b border-border shrink-0 pr-12">
<div class="size-7 rounded-lg bg-primary/10 flex items-center justify-center ring-1 ring-primary/20 shrink-0">
<Sparkles class="size-3.5 text-primary" />
</div>
<p class="text-sm font-semibold shrink-0">Search Metadata</p>
<ChevronRight class="size-3.5 text-border shrink-0 hidden sm:block" />
<p class="text-xs text-muted-foreground truncate hidden sm:block">{{ book.title }}</p>
<!-- Step indicator -->
<div class="ml-auto flex items-center gap-1 shrink-0">
<div class="h-1.5 w-6 rounded-full transition-all duration-300" :class="view === 'search' ? 'bg-primary' : 'bg-border'" />
<div class="h-1.5 w-6 rounded-full transition-all duration-300" :class="view === 'diff' ? 'bg-primary' : 'bg-border'" />
</div>
</div>
<!-- Content -->
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { Search } from 'lucide-vue-next'
import { Search, BookOpen, Loader2 } from 'lucide-vue-next'
import type { BookDetail, MetadataCandidate, MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
import MetadataResultCard from './MetadataResultCard.vue'
import { providerBadgeStyle, providerActivePillStyle } from '../../../lib/metadata-fetch'
const props = defineProps<{
book: BookDetail
@@ -36,48 +37,55 @@ function runSearch() {
<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>
<!-- Search form card -->
<div class="px-4 pt-4 pb-3 shrink-0">
<div class="rounded-xl border border-border bg-card p-3 space-y-2 shadow-sm">
<div class="flex gap-2">
<div class="relative flex-1">
<BookOpen class="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<input
v-model="form.title"
class="w-full h-8 rounded-lg border border-input bg-background pl-8 pr-3 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow"
placeholder="Title"
@keydown.enter="runSearch"
/>
</div>
<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="relative flex items-center gap-1.5 h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all disabled:opacity-40 hover:opacity-90 active:scale-95 overflow-hidden group"
:disabled="!canSearch || isStreaming"
@click="runSearch"
>
<span class="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity" />
<Loader2 v-if="isStreaming" class="size-3.5 animate-spin" />
<Search v-else class="size-3.5" />
{{ isStreaming ? 'Searching...' : 'Search' }}
</button>
</div>
</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="h-6 px-2.5 rounded-full text-xs font-medium transition-all active:scale-95"
:class="
!selectedProviders.length
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground'
? 'bg-primary text-primary-foreground shadow-sm shadow-primary/20'
: 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80'
"
@click="$emit('clearFilter')"
>
@@ -86,32 +94,53 @@ function runSearch() {
<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'
"
class="h-6 px-2.5 rounded-full text-xs font-medium transition-all flex items-center gap-1 active:scale-95"
:class="(selectedProviders.includes(p.key) || !selectedProviders.length) ? '' : 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80'"
:style="(selectedProviders.includes(p.key) || !selectedProviders.length) ? providerActivePillStyle(p.key) : {}"
@click="$emit('toggleProvider', p.key)"
>
{{ p.label }}
<span v-if="providerCounts[p.key]" class="opacity-70">{{ providerCounts[p.key] }}</span>
<span
v-if="providerCounts[p.key]"
class="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full text-[10px] font-semibold bg-black/10"
>
{{ 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>
<!-- 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="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" />
<div class="h-4 bg-muted rounded-md w-1/3 mt-1" />
</div>
</div>
</div>
<div v-else-if="!isStreaming && !filteredResults.length && hasSearched" class="py-8 text-center text-sm text-muted-foreground">
No results found.
<!-- No results -->
<div v-else-if="!isStreaming && !filteredResults.length && hasSearched" class="py-12 flex flex-col items-center gap-3 text-muted-foreground">
<div class="size-10 rounded-full bg-muted flex items-center justify-center">
<BookOpen class="size-5" />
</div>
<div class="text-center">
<p class="text-sm font-medium text-foreground">No results found</p>
<p class="text-xs text-muted-foreground mt-0.5">Try adjusting the title or author</p>
</div>
</div>
<div v-else class="space-y-0.5">
<!-- Results grid -->
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<MetadataResultCard
v-for="(candidate, i) in filteredResults"
:key="`${candidate.provider}-${candidate.providerId}-${i}`"
@@ -1,5 +1,5 @@
import { computed, reactive } from 'vue'
import type { BookDetail, MetadataCandidate } from '@projectx/types'
import type { BookDetail, MetadataCandidate, MetadataProviderKey } from '@projectx/types'
export type DiffFieldKey =
| 'title'
@@ -42,9 +42,15 @@ export interface MetadataPatch {
isbn13?: string | null
authors?: string[]
tags?: string[]
googleBooksId?: string | null
goodreadsId?: string | null
amazonId?: string | null
hardcoverId?: string | null
openLibraryId?: string | null
}
const FIELD_DEFS: { key: DiffFieldKey; label: string }[] = [
{ key: 'coverUrl', label: 'Cover' },
{ key: 'title', label: 'Title' },
{ key: 'subtitle', label: 'Subtitle' },
{ key: 'authors', label: 'Authors' },
@@ -58,9 +64,16 @@ const FIELD_DEFS: { key: DiffFieldKey; label: string }[] = [
{ key: 'isbn13', label: 'ISBN-13' },
{ key: 'isbn10', label: 'ISBN-10' },
{ key: 'tags', label: 'Tags' },
{ key: 'coverUrl', label: 'Cover' },
]
const PROVIDER_ID_FIELD: Record<MetadataProviderKey, keyof MetadataPatch | undefined> = {
google: 'googleBooksId',
goodreads: 'goodreadsId',
amazon: 'amazonId',
hardcover: 'hardcoverId',
openLibrary: 'openLibraryId',
}
export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate) {
const copiedFields = reactive(new Set<DiffFieldKey>())
@@ -129,6 +142,11 @@ export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate)
;(formPatch as Record<string, unknown>)[key] = val != null ? String(val) : null
}
if (copiedFields.size > 0) {
const idField = PROVIDER_ID_FIELD[candidate.provider]
if (idField) formPatch[idField] = candidate.providerId
}
return { formPatch, coverUrl }
}
@@ -21,6 +21,11 @@ export function useMetadataEditor() {
rating: null as number | null,
authors: [] as string[],
tags: [] as string[],
googleBooksId: null as string | null,
goodreadsId: null as string | null,
amazonId: null as string | null,
hardcoverId: null as string | null,
openLibraryId: null as string | null,
})
let snapshot = JSON.stringify(form)
@@ -42,6 +47,11 @@ export function useMetadataEditor() {
form.rating = book.rating ?? null
form.authors = book.authors.map((a) => a.name)
form.tags = [...book.tags]
form.googleBooksId = book.providerIds.google ?? null
form.goodreadsId = book.providerIds.goodreads ?? null
form.amazonId = book.providerIds.amazon ?? null
form.hardcoverId = book.providerIds.hardcover ?? null
form.openLibraryId = book.providerIds.openLibrary ?? null
snapshot = JSON.stringify(form)
error.value = null
}
@@ -83,11 +83,33 @@ export function useMetadataSearch() {
}
}
const filteredResults = computed(() =>
selectedProviders.value.length
const PROVIDER_ORDER: MetadataProviderKey[] = ['amazon', 'goodreads', 'hardcover', 'google', 'openLibrary']
function sortResults(list: MetadataCandidate[]): MetadataCandidate[] {
const byProvider = new Map<string, MetadataCandidate[]>()
for (const r of list) {
const bucket = byProvider.get(r.provider) ?? []
bucket.push(r)
byProvider.set(r.provider, bucket)
}
const orderedKeys = [
...PROVIDER_ORDER.filter((p) => byProvider.has(p)),
...[...byProvider.keys()].filter((p) => !PROVIDER_ORDER.includes(p as MetadataProviderKey)),
]
const out: MetadataCandidate[] = []
for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(0, 2) ?? []))
for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(2) ?? []))
return out
}
const filteredResults = computed(() => {
const filtered = selectedProviders.value.length
? results.value.filter((r) => selectedProviders.value.includes(r.provider))
: results.value,
)
: results.value
return sortResults(filtered)
})
function toggleProvider(key: MetadataProviderKey) {
const idx = selectedProviders.value.indexOf(key)
@@ -7,3 +7,36 @@ export function getProviderLabel(provider: MetadataProviderKey, providers: Metad
export function hideOnError(e: Event): void {
(e.target as HTMLImageElement).style.visibility = 'hidden'
}
/** Brand colors for each provider - used for pill/badge tinting. */
const PROVIDER_COLORS: Record<string, string> = {
google: '#34A853',
goodreads: '#B8722A',
amazon: '#FF9900',
hardcover: '#4F47E5',
openLibrary: '#E83B2A',
}
const DEFAULT_COLOR = 'oklch(0.5 0.01 0)'
export function getProviderColor(provider: string): string {
return PROVIDER_COLORS[provider] ?? DEFAULT_COLOR
}
function makeProviderPillStyle(color: string, bgPct: number, outlinePct: number): Record<string, string> {
return {
backgroundColor: `color-mix(in srgb, ${color} ${bgPct}%, transparent)`,
color,
outlineColor: `color-mix(in srgb, ${color} ${outlinePct}%, transparent)`,
outlineWidth: '1px',
outlineStyle: 'solid',
}
}
export function providerBadgeStyle(provider: string): Record<string, string> {
return makeProviderPillStyle(getProviderColor(provider), 12, 30)
}
export function providerActivePillStyle(provider: string): Record<string, string> {
return makeProviderPillStyle(getProviderColor(provider), 22, 45)
}
+1 -18
View File
@@ -1,25 +1,21 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import AppSidebar from '@/components/AppSidebar.vue'
import AppHeader from '@/components/AppHeader.vue'
import BookDetailHeader from '@/features/book/components/detail/BookDetailHeader.vue'
import BookDetailTabs from '@/features/book/components/detail/BookDetailTabs.vue'
import DetailsTab from '@/features/book/components/detail/tabs/DetailsTab.vue'
import { useBookDetail } from '@/features/book/composables/useBookDetail'
import { useBookEvents } from '@/features/book/composables/useBookEvents'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
const route = useRoute()
const router = useRouter()
const bookId = computed(() => Number(route.params.bookId))
const { detail, loading, fetch } = useBookDetail()
const { libraries, fetchLibraries } = useLibraries()
const { subscribeLibrary } = useScanProgress()
watch(
@@ -43,21 +39,9 @@ onBookMoved((bookIds) => {
})
watch(bookId, (id) => fetch(id), { immediate: true })
fetchLibraries()
const libraryName = computed(() => libraries.value.find((l) => l.id === detail.value?.libraryId)?.name ?? 'Library')
const themeStore = useThemeStore()
const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === themeStore.background)?.cssClass ?? '')
function goBack() {
if (window.history.state?.back) {
router.back()
} else {
const libId = detail.value?.libraryId
router.push(libId ? { name: 'library', params: { id: libId } } : { name: 'home' })
}
}
</script>
<template>
@@ -66,7 +50,6 @@ function goBack() {
<SidebarInset class="flex flex-col min-h-screen overflow-x-hidden">
<AppHeader />
<div class="flex items-center border-b shrink-0 h-11">
<BookDetailHeader :library-name="libraryName" :loading="loading" @back="goBack" />
<BookDetailTabs :book-id="bookId" />
</div>
+1 -18
View File
@@ -1,42 +1,26 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import type { BookDetail } from '@projectx/types'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import AppSidebar from '@/components/AppSidebar.vue'
import AppHeader from '@/components/AppHeader.vue'
import BookDetailHeader from '@/features/book/components/detail/BookDetailHeader.vue'
import BookDetailTabs from '@/features/book/components/detail/BookDetailTabs.vue'
import EditMetadataTab from '@/features/book/components/detail/tabs/EditMetadataTab.vue'
import { useBookDetail } from '@/features/book/composables/useBookDetail'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
const route = useRoute()
const router = useRouter()
const bookId = computed(() => Number(route.params.bookId))
const { detail, loading, fetch } = useBookDetail()
const { libraries, fetchLibraries } = useLibraries()
watch(bookId, (id) => fetch(id), { immediate: true })
fetchLibraries()
const themeStore = useThemeStore()
const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === themeStore.background)?.cssClass ?? '')
const libraryName = computed(() => libraries.value.find((l) => l.id === detail.value?.libraryId)?.name ?? 'Library')
function goBack() {
if (window.history.state?.back) {
router.back()
} else {
const libId = detail.value?.libraryId
router.push(libId ? { name: 'library', params: { id: libId } } : { name: 'home' })
}
}
function onMetadataSaved(updated: BookDetail) {
detail.value = updated
}
@@ -52,7 +36,6 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
<SidebarInset class="flex flex-col min-h-screen overflow-x-hidden">
<AppHeader />
<div class="flex items-center border-b shrink-0 h-11">
<BookDetailHeader :library-name="libraryName" :loading="loading" @back="goBack" />
<BookDetailTabs :book-id="bookId" />
</div>
+1 -18
View File
@@ -1,40 +1,24 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import AppSidebar from '@/components/AppSidebar.vue'
import AppHeader from '@/components/AppHeader.vue'
import BookDetailHeader from '@/features/book/components/detail/BookDetailHeader.vue'
import BookDetailTabs from '@/features/book/components/detail/BookDetailTabs.vue'
import FilesTab from '@/features/book/components/detail/tabs/FilesTab.vue'
import { useBookDetail } from '@/features/book/composables/useBookDetail'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
const route = useRoute()
const router = useRouter()
const bookId = computed(() => Number(route.params.bookId))
const { detail, loading, fetch } = useBookDetail()
const { libraries, fetchLibraries } = useLibraries()
watch(bookId, (id) => fetch(id), { immediate: true })
fetchLibraries()
const themeStore = useThemeStore()
const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === themeStore.background)?.cssClass ?? '')
const libraryName = computed(() => libraries.value.find((l) => l.id === detail.value?.libraryId)?.name ?? 'Library')
function goBack() {
if (window.history.state?.back) {
router.back()
} else {
const libId = detail.value?.libraryId
router.push(libId ? { name: 'library', params: { id: libId } } : { name: 'home' })
}
}
</script>
<template>
@@ -43,7 +27,6 @@ function goBack() {
<SidebarInset class="flex flex-col min-h-screen overflow-x-hidden">
<AppHeader />
<div class="flex items-center border-b shrink-0 h-11">
<BookDetailHeader :library-name="libraryName" :loading="loading" @back="goBack" />
<BookDetailTabs :book-id="bookId" />
</div>
+5
View File
@@ -1,3 +1,5 @@
import type { MetadataProviderKey } from './metadata-fetch';
export type BookFileRef = {
id: number;
format: string | null;
@@ -30,6 +32,8 @@ export type BookDetailFile = {
filename: string | null;
};
export type ProviderIds = Partial<Record<MetadataProviderKey, string | null>>;
export type BookDetail = {
id: number;
libraryId: number;
@@ -48,6 +52,7 @@ export type BookDetail = {
seriesIndex: number | null;
rating: number | null;
coverSource: 'extracted' | 'custom' | null;
providerIds: ProviderIds;
authors: { id: number; name: string; sortName: string | null }[];
tags: string[];
files: BookDetailFile[];
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1771535401494,
"when": 1771540165274,
"tag": "0000_add_provider_ids",
"breakpoints": true
}
+13
View File
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { access, readdir, rm, stat } from 'fs/promises';
import { basename, join } from 'path';
import { MetadataProviderKey } from '@projectx/types';
import type { BookCard, BookQuery, BooksPage } from '@projectx/types';
import type { RequestUser } from '../../common/types/request-user';
import { MetadataService } from '../metadata/metadata.service';
@@ -214,6 +215,11 @@ export class BookService {
if ('isbn10' in dto) scalarFields.isbn10 = dto.isbn10 ?? null;
if ('isbn13' in dto) scalarFields.isbn13 = dto.isbn13 ?? null;
if ('rating' in dto) scalarFields.rating = dto.rating ?? null;
if ('googleBooksId' in dto) scalarFields.googleBooksId = dto.googleBooksId ?? null;
if ('goodreadsId' in dto) scalarFields.goodreadsId = dto.goodreadsId ?? null;
if ('amazonId' in dto) scalarFields.amazonId = dto.amazonId ?? null;
if ('hardcoverId' in dto) scalarFields.hardcoverId = dto.hardcoverId ?? null;
if ('openLibraryId' in dto) scalarFields.openLibraryId = dto.openLibraryId ?? null;
if (Object.keys(scalarFields).length > 0) {
scalarFields.updatedAt = new Date();
@@ -269,6 +275,13 @@ export class BookService {
seriesIndex: meta?.seriesIndex ?? null,
rating: meta?.rating ?? null,
coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null,
providerIds: {
[MetadataProviderKey.GOOGLE]: meta?.googleBooksId ?? null,
[MetadataProviderKey.GOODREADS]: meta?.goodreadsId ?? null,
[MetadataProviderKey.AMAZON]: meta?.amazonId ?? null,
[MetadataProviderKey.HARDCOVER]: meta?.hardcoverId ?? null,
[MetadataProviderKey.OPEN_LIBRARY]: meta?.openLibraryId ?? null,
},
authors: authorRows,
tags: tagRows.map((t) => t.name),
files: fileRows.map((f) => ({
@@ -1,3 +1,5 @@
import type { ProviderIds } from '@projectx/types';
export class BookFileDto {
id: number;
format: string | null;
@@ -26,6 +28,7 @@ export class BookDetailDto {
seriesIndex: number | null;
rating: number | null;
coverSource: 'extracted' | 'custom' | null;
providerIds: ProviderIds;
authors: { id: number; name: string; sortName: string | null }[];
tags: string[];
files: BookFileDto[];
@@ -15,4 +15,9 @@ export class UpdateBookMetadataDto {
@IsOptional() @IsInt() @Min(1) @Max(5) rating?: number | null;
@IsOptional() @IsArray() @IsString({ each: true }) authors?: string[];
@IsOptional() @IsArray() @IsString({ each: true }) tags?: string[];
@IsOptional() @IsString() @MaxLength(50) googleBooksId?: string | null;
@IsOptional() @IsString() @MaxLength(50) goodreadsId?: string | null;
@IsOptional() @IsString() @MaxLength(20) amazonId?: string | null;
@IsOptional() @IsString() @MaxLength(50) hardcoverId?: string | null;
@IsOptional() @IsString() @MaxLength(50) openLibraryId?: string | null;
}