mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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,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,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,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>
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1771535401494,
|
||||
"when": 1771540165274,
|
||||
"tag": "0000_add_provider_ids",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user