mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(authors): fix photo filtering and improve metadata comparison panel
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, ref, watch } from 'vue'
|
||||
import { ArrowLeft, CopyCheck, Copy, CheckCheck } from 'lucide-vue-next'
|
||||
import { computed, inject, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ArrowLeft, CopyCheck, Copy, CheckCheck, Eye, EyeOff, Info } from 'lucide-vue-next'
|
||||
import type { MetadataCandidate, MetadataProviderInfo, MetadataProviderKey, MetadataSource, ProviderIds } from '@projectx/types'
|
||||
import { useMetadataDiff, type DiffFieldKey, type MetadataPatch } from '../../../composables/useMetadataDiff'
|
||||
import { getProviderLabel, getProviderColor, hideOnError, providerBadgeStyle, providerActivePillStyle } from '../../../lib/metadata-fetch'
|
||||
import { getProviderColor, getProviderLabel, hideOnError, providerActivePillStyle } from '../../../lib/metadata-fetch'
|
||||
import MetadataDiffRow from './MetadataDiffRow.vue'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
|
||||
|
||||
@@ -26,13 +26,22 @@ const emit = defineEmits<{
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
|
||||
const activeProvider = ref<MetadataProviderKey>(props.initialCandidate.provider)
|
||||
const showUnchanged = ref(false)
|
||||
const showSwitchHint = ref(false)
|
||||
|
||||
let switchHintTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
interface ProviderResultMeta {
|
||||
total: number
|
||||
selectedIndex: number
|
||||
}
|
||||
|
||||
// Tracks which specific result the user has picked per provider tab
|
||||
const selectedPerProvider = reactive(new Map<MetadataProviderKey, MetadataCandidate>())
|
||||
selectedPerProvider.set(props.initialCandidate.provider, props.initialCandidate)
|
||||
for (const c of props.filteredResults) {
|
||||
if (!selectedPerProvider.has(c.provider)) {
|
||||
selectedPerProvider.set(c.provider, c)
|
||||
for (const candidate of props.filteredResults) {
|
||||
if (!selectedPerProvider.has(candidate.provider)) {
|
||||
selectedPerProvider.set(candidate.provider, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +54,10 @@ const representativeCandidates = computed<MetadataCandidate[]>(() => {
|
||||
out.push(initSelected)
|
||||
seen.add(initSelected.provider)
|
||||
|
||||
for (const c of props.filteredResults) {
|
||||
if (!seen.has(c.provider)) {
|
||||
seen.add(c.provider)
|
||||
out.push(selectedPerProvider.get(c.provider) ?? c)
|
||||
for (const candidate of props.filteredResults) {
|
||||
if (!seen.has(candidate.provider)) {
|
||||
seen.add(candidate.provider)
|
||||
out.push(selectedPerProvider.get(candidate.provider) ?? candidate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,14 +65,16 @@ const representativeCandidates = computed<MetadataCandidate[]>(() => {
|
||||
})
|
||||
|
||||
watch(representativeCandidates, (list) => {
|
||||
if (!list.find((c) => c.provider === activeProvider.value)) {
|
||||
if (!list.find((candidate) => candidate.provider === activeProvider.value)) {
|
||||
activeProvider.value = list[0]?.provider ?? props.initialCandidate.provider
|
||||
}
|
||||
})
|
||||
|
||||
const activeCandidate = computed(
|
||||
() =>
|
||||
representativeCandidates.value.find((c) => c.provider === activeProvider.value) ?? representativeCandidates.value[0] ?? props.initialCandidate,
|
||||
representativeCandidates.value.find((candidate) => candidate.provider === activeProvider.value) ??
|
||||
representativeCandidates.value[0] ??
|
||||
props.initialCandidate,
|
||||
)
|
||||
|
||||
const { fields, picksPerProvider, toggleField, pickFieldFromProvider, clearPicksForProvider, copyAll, copyMissing, buildPatch, hasCopied } =
|
||||
@@ -76,18 +87,89 @@ const { fields, picksPerProvider, toggleField, pickFieldFromProvider, clearPicks
|
||||
computed(() => props.providerIds),
|
||||
)
|
||||
|
||||
const hasMultipleProviders = computed(() => representativeCandidates.value.length > 1)
|
||||
const activeProviderLabel = computed(() => getProviderLabel(activeProvider.value, props.providers))
|
||||
const resultsForActiveProvider = computed(() => props.filteredResults.filter((candidate) => candidate.provider === activeProvider.value))
|
||||
|
||||
const resultsForActiveProvider = computed(() => props.filteredResults.filter((c) => c.provider === activeProvider.value))
|
||||
const selectedFieldCount = computed(() => {
|
||||
let total = 0
|
||||
for (const count of picksPerProvider.value.values()) {
|
||||
total += count
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
function isSelectedForTab(c: MetadataCandidate): boolean {
|
||||
const selected = selectedPerProvider.get(c.provider)
|
||||
return !!selected && selected.providerId === c.providerId
|
||||
const selectedCountLabel = computed(() => {
|
||||
if (!selectedFieldCount.value) return 'No fields selected'
|
||||
return `${selectedFieldCount.value} field${selectedFieldCount.value === 1 ? '' : 's'} selected`
|
||||
})
|
||||
|
||||
const providerResultMeta = computed(() => {
|
||||
const meta = new Map<MetadataProviderKey, ProviderResultMeta>()
|
||||
for (const candidate of representativeCandidates.value) {
|
||||
const matches = props.filteredResults.filter((c) => c.provider === candidate.provider)
|
||||
const selected = selectedPerProvider.get(candidate.provider) ?? candidate
|
||||
const idx = matches.findIndex((match) => isSameCandidate(match, selected))
|
||||
meta.set(candidate.provider, {
|
||||
total: matches.length || 1,
|
||||
selectedIndex: idx >= 0 ? idx + 1 : 1,
|
||||
})
|
||||
}
|
||||
return meta
|
||||
})
|
||||
|
||||
const activeResultMeta = computed(
|
||||
() =>
|
||||
providerResultMeta.value.get(activeProvider.value) ?? {
|
||||
total: 1,
|
||||
selectedIndex: 1,
|
||||
},
|
||||
)
|
||||
|
||||
const visibleFields = computed(() => fields.value.filter((field) => showUnchanged.value || field.hasDiff || !field.bookValue))
|
||||
|
||||
function isSameCandidate(a: MetadataCandidate, b: MetadataCandidate): boolean {
|
||||
if (a.provider !== b.provider) return false
|
||||
if (a.providerId && b.providerId) return a.providerId === b.providerId
|
||||
return a === b
|
||||
}
|
||||
|
||||
function selectResultForTab(c: MetadataCandidate) {
|
||||
selectedPerProvider.set(c.provider, c)
|
||||
clearPicksForProvider(c.provider)
|
||||
function isActiveProvider(provider: MetadataProviderKey): boolean {
|
||||
return activeProvider.value === provider
|
||||
}
|
||||
|
||||
function getProviderAvailableCount(provider: MetadataProviderKey): number {
|
||||
return providerResultMeta.value.get(provider)?.total ?? 0
|
||||
}
|
||||
|
||||
function isSelectedForTab(candidate: MetadataCandidate): boolean {
|
||||
const selected = selectedPerProvider.get(candidate.provider)
|
||||
if (!selected) return false
|
||||
return isSameCandidate(selected, candidate)
|
||||
}
|
||||
|
||||
function clearSwitchHintTimer() {
|
||||
if (switchHintTimer) {
|
||||
clearTimeout(switchHintTimer)
|
||||
switchHintTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function showResultSwitchHint() {
|
||||
showSwitchHint.value = true
|
||||
clearSwitchHintTimer()
|
||||
switchHintTimer = setTimeout(() => {
|
||||
showSwitchHint.value = false
|
||||
switchHintTimer = null
|
||||
}, 2800)
|
||||
}
|
||||
|
||||
function selectResultForTab(candidate: MetadataCandidate) {
|
||||
const current = selectedPerProvider.get(candidate.provider)
|
||||
if (current && isSameCandidate(current, candidate)) return
|
||||
const hadSelections = (picksPerProvider.value.get(candidate.provider) ?? 0) > 0
|
||||
selectedPerProvider.set(candidate.provider, candidate)
|
||||
clearPicksForProvider(candidate.provider)
|
||||
if (hadSelections) showResultSwitchHint()
|
||||
}
|
||||
|
||||
function setActiveProvider(provider: MetadataProviderKey) {
|
||||
@@ -98,122 +180,155 @@ function handlePickFromProvider(key: DiffFieldKey, provider: MetadataProviderKey
|
||||
pickFieldFromProvider(key, provider)
|
||||
}
|
||||
|
||||
function handleCopyAll() {
|
||||
copyAll()
|
||||
}
|
||||
|
||||
function handleCopyMissing() {
|
||||
copyMissing()
|
||||
}
|
||||
|
||||
function toggleShowUnchanged() {
|
||||
showUnchanged.value = !showUnchanged.value
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
emit('back')
|
||||
}
|
||||
|
||||
function apply() {
|
||||
emit('apply', buildPatch())
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearSwitchHintTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col shrink-0 border-b border-border bg-card/50">
|
||||
<!-- Top row -->
|
||||
<div class="flex items-start gap-3 px-4 py-3">
|
||||
<div class="shrink-0 border-b border-border bg-card/50 px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
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')"
|
||||
class="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0 group h-8 px-2.5 rounded-lg border border-border bg-background"
|
||||
@click="goBack"
|
||||
>
|
||||
<ArrowLeft class="size-4 transition-transform group-hover:-translate-x-0.5" />
|
||||
{{ backLabel ?? 'Results' }}
|
||||
</button>
|
||||
<div
|
||||
v-if="activeCandidate.coverUrl"
|
||||
class="shrink-0 w-8 rounded overflow-hidden bg-muted ring-1 ring-border"
|
||||
:style="{ aspectRatio: coverAspectRatio }"
|
||||
>
|
||||
<img
|
||||
:src="activeCandidate.coverUrl"
|
||||
alt=""
|
||||
class="w-full h-full object-cover"
|
||||
@error="($event.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold truncate leading-snug">{{ activeCandidate.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(activeCandidate.provider)">
|
||||
{{ getProviderLabel(activeCandidate.provider, providers) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 overflow-x-auto whitespace-nowrap pb-0.5">
|
||||
<button
|
||||
v-for="candidate in representativeCandidates"
|
||||
:key="candidate.provider"
|
||||
class="h-6 px-2.5 rounded-full border inline-flex items-center gap-1.5 text-xs font-medium transition-all active:scale-[0.99] shrink-0"
|
||||
:class="
|
||||
isActiveProvider(candidate.provider)
|
||||
? 'text-foreground'
|
||||
: 'border-border bg-background hover:bg-muted text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
:style="isActiveProvider(candidate.provider) ? providerActivePillStyle(candidate.provider) : {}"
|
||||
@click="setActiveProvider(candidate.provider)"
|
||||
>
|
||||
<span>{{ getProviderLabel(candidate.provider, providers) }}</span>
|
||||
<span class="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full text-[10px] font-semibold bg-black/10">
|
||||
{{ getProviderAvailableCount(candidate.provider) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1.5 shrink-0">
|
||||
</div>
|
||||
|
||||
<!-- Within-provider result picker -->
|
||||
<div v-if="resultsForActiveProvider.length > 1" class="mt-2.5">
|
||||
<div class="flex items-center justify-between gap-2 mb-1.5">
|
||||
<p class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">{{ activeProviderLabel }} matches</p>
|
||||
<p class="text-[10px] text-muted-foreground">Selected {{ activeResultMeta.selectedIndex }} of {{ activeResultMeta.total }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-0.5">
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-all active:scale-95"
|
||||
@click="copyMissing"
|
||||
v-for="(candidate, index) in resultsForActiveProvider"
|
||||
:key="candidate.providerId ?? index"
|
||||
class="shrink-0 w-52 rounded-lg border bg-background/70 px-2 py-1.5 flex items-center gap-2 text-left transition-all active:scale-[0.98]"
|
||||
:class="isSelectedForTab(candidate) ? 'border-primary/50 bg-primary/6' : 'border-border/60 hover:border-border'"
|
||||
:style="isSelectedForTab(candidate) ? { boxShadow: `0 0 0 1px ${getProviderColor(candidate.provider)}` } : {}"
|
||||
@click="selectResultForTab(candidate)"
|
||||
>
|
||||
<Copy class="size-3" />
|
||||
Copy Missing
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-primary/40 bg-primary/10 text-primary text-xs font-medium hover:bg-primary/20 transition-all active:scale-95"
|
||||
@click="copyAll"
|
||||
>
|
||||
<CopyCheck class="size-3" />
|
||||
Copy All
|
||||
<span class="relative w-10 shrink-0 rounded-md overflow-hidden bg-muted ring-1 ring-border/70" :style="{ aspectRatio: coverAspectRatio }">
|
||||
<img v-if="candidate.coverUrl" :src="candidate.coverUrl" alt="" class="w-full h-full object-cover" @error="hideOnError" />
|
||||
<span v-else class="block w-full h-full bg-muted-foreground/10" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-xs font-medium text-foreground line-clamp-1">{{ candidate.title }}</span>
|
||||
<span class="block text-[10px] text-muted-foreground line-clamp-1">
|
||||
{{ candidate.publishedYear ?? 'Year unknown' }} - {{ index + 1 }} of {{ resultsForActiveProvider.length }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider tabs -->
|
||||
<div v-if="hasMultipleProviders" class="flex items-center gap-1.5 px-4 pb-2.5 overflow-x-auto">
|
||||
<button
|
||||
v-for="c in representativeCandidates"
|
||||
:key="c.provider"
|
||||
class="relative flex items-center gap-1.5 h-6 px-2.5 rounded-full text-xs font-medium transition-all shrink-0 active:scale-95"
|
||||
:style="activeProvider === c.provider ? providerActivePillStyle(c.provider) : {}"
|
||||
:class="activeProvider !== c.provider ? 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80' : ''"
|
||||
@click="setActiveProvider(c.provider)"
|
||||
>
|
||||
{{ getProviderLabel(c.provider, providers) }}
|
||||
<span
|
||||
v-if="(picksPerProvider.get(c.provider) ?? 0) > 0"
|
||||
class="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full text-[10px] font-semibold bg-black/10"
|
||||
>
|
||||
{{ picksPerProvider.get(c.provider) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Within-tab result picker (shown when active provider has multiple results) -->
|
||||
<div v-if="resultsForActiveProvider.length > 1" class="flex items-center gap-2 px-4 pb-2.5 overflow-x-auto">
|
||||
<p class="text-[10px] text-muted-foreground shrink-0">Result:</p>
|
||||
<button
|
||||
v-for="(c, i) in resultsForActiveProvider"
|
||||
:key="c.providerId ?? i"
|
||||
class="shrink-0 rounded-md overflow-hidden bg-muted ring-1 transition-all active:scale-95"
|
||||
:class="isSelectedForTab(c) ? 'ring-2' : 'ring-border/60 hover:ring-border'"
|
||||
:style="[
|
||||
{ width: '28px', aspectRatio: coverAspectRatio },
|
||||
isSelectedForTab(c) ? { outline: 'none', boxShadow: `0 0 0 2px ${getProviderColor(c.provider)}` } : {},
|
||||
]"
|
||||
:title="`${c.title}${c.publishedYear ? ' · ' + c.publishedYear : ''}`"
|
||||
@click="selectResultForTab(c)"
|
||||
>
|
||||
<img v-if="c.coverUrl" :src="c.coverUrl" alt="" class="w-full h-full object-cover" @error="hideOnError" />
|
||||
<div v-else class="w-full h-full bg-muted-foreground/10" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column labels -->
|
||||
<div class="grid grid-cols-[1fr_auto_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 class="w-11" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="size-1.5 rounded-full" :style="{ backgroundColor: getProviderColor(activeProvider) }" />
|
||||
<p class="text-xs font-semibold uppercase tracking-wider" :style="{ color: getProviderColor(activeProvider) }">
|
||||
{{ getProviderLabel(activeProvider, providers) }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="showSwitchHint" class="mt-2 text-[11px] text-muted-foreground flex items-center gap-1.5">
|
||||
<Info class="size-3.5" />
|
||||
Switched result. Previous selections from this provider were cleared.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Diff rows -->
|
||||
<div class="flex-1 overflow-y-auto px-4">
|
||||
<div class="sticky top-0 z-10 -mx-4 px-4 py-2.5 border-b border-border bg-background/95 backdrop-blur-sm">
|
||||
<div class="flex items-center gap-1.5 pb-1">
|
||||
<div class="min-w-0 flex items-center gap-1.5 overflow-x-auto whitespace-nowrap pr-2">
|
||||
<p v-if="hasCopied" class="shrink-0 text-xs text-muted-foreground inline-flex items-center gap-1.5">
|
||||
<CheckCheck class="size-3.5 text-primary" />
|
||||
{{ selectedCountLabel }}
|
||||
</p>
|
||||
<p v-else class="shrink-0 text-xs text-muted-foreground">Select fields to apply</p>
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-all active:scale-95"
|
||||
@click="handleCopyMissing"
|
||||
>
|
||||
<Copy class="size-3" />
|
||||
Copy Missing
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-primary/40 bg-primary/10 text-primary text-xs font-medium hover:bg-primary/20 transition-all active:scale-95"
|
||||
@click="handleCopyAll"
|
||||
>
|
||||
<CopyCheck class="size-3" />
|
||||
Copy All
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="h-7 px-2.5 rounded-lg border border-border bg-background text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-all active:scale-95 inline-flex items-center gap-1.5 shrink-0 ml-auto"
|
||||
@click="toggleShowUnchanged"
|
||||
>
|
||||
<EyeOff v-if="showUnchanged" class="size-3.5" />
|
||||
<Eye v-else class="size-3.5" />
|
||||
{{ showUnchanged ? 'Hide unchanged' : 'Show unchanged' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] gap-2 mt-2.5">
|
||||
<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 class="w-11" />
|
||||
<div class="flex items-center gap-1.5 min-w-0 overflow-hidden">
|
||||
<div class="size-1.5 rounded-full" :style="{ backgroundColor: getProviderColor(activeProvider) }" />
|
||||
<p class="text-xs font-semibold tracking-wider shrink-0" :style="{ color: getProviderColor(activeProvider) }">
|
||||
{{ activeProviderLabel }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate min-w-0">({{ activeCandidate.title }})</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MetadataDiffRow
|
||||
v-for="field in fields"
|
||||
v-for="field in visibleFields"
|
||||
:key="field.key"
|
||||
:field="field"
|
||||
:active-provider="activeProvider"
|
||||
@@ -221,21 +336,15 @@ function apply() {
|
||||
@toggle="toggleField"
|
||||
@pick-from-provider="handlePickFromProvider"
|
||||
/>
|
||||
<p v-if="fields.length === 0" class="py-8 text-center text-sm text-muted-foreground">No metadata available from this source.</p>
|
||||
<p v-if="visibleFields.length === 0" class="py-8 text-center text-sm text-muted-foreground">
|
||||
{{ showUnchanged ? 'No metadata available from this source.' : 'No changed or missing fields. Use Show unchanged to inspect everything.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<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')"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 px-4 py-3 border-t border-border shrink-0 bg-card/30">
|
||||
<div class="flex flex-wrap items-center 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="goBack">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { ArrowLeft, RotateCcw, CheckCircle2, X, ZoomIn, Layers } from 'lucide-vue-next'
|
||||
import type { MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
|
||||
import type { DiffField, DiffFieldKey } from '../../../composables/useMetadataDiff'
|
||||
@@ -21,12 +21,41 @@ const emit = defineEmits<{
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
|
||||
const lightboxSrc = ref<string | null>(null)
|
||||
const isCurrentExpanded = ref(false)
|
||||
const isCandidateExpanded = ref(false)
|
||||
|
||||
const pickedProviderLabel = computed(() => {
|
||||
if (!props.field.pickedProvider) return ''
|
||||
return props.field.providerValues.find((pv) => pv.provider === props.field.pickedProvider)?.label ?? props.field.pickedProvider
|
||||
})
|
||||
|
||||
const CLAMPABLE_TEXT_FIELDS = new Set<DiffFieldKey>([
|
||||
'title',
|
||||
'subtitle',
|
||||
'authors',
|
||||
'description',
|
||||
'genres',
|
||||
'narrators',
|
||||
'comicPencillers',
|
||||
'comicInkers',
|
||||
'comicColorists',
|
||||
'comicLetterers',
|
||||
'comicCoverArtists',
|
||||
'comicCharacters',
|
||||
'comicTeams',
|
||||
'comicLocations',
|
||||
'comicStoryArcs',
|
||||
])
|
||||
|
||||
const canClampCurrent = computed(
|
||||
() => CLAMPABLE_TEXT_FIELDS.has(props.field.key) && props.field.currentDisplay.length > 160 && props.field.key !== 'sourceUrl',
|
||||
)
|
||||
const canClampCandidate = computed(
|
||||
() => CLAMPABLE_TEXT_FIELDS.has(props.field.key) && props.field.candidateDisplay.length > 160 && props.field.key !== 'sourceUrl',
|
||||
)
|
||||
const currentTextClass = computed(() => (canClampCurrent.value && !isCurrentExpanded.value ? 'line-clamp-5' : ''))
|
||||
const candidateTextClass = computed(() => (canClampCandidate.value && !isCandidateExpanded.value ? 'line-clamp-5' : ''))
|
||||
|
||||
function handleToggle() {
|
||||
emit('toggle', props.field.key)
|
||||
}
|
||||
@@ -34,6 +63,22 @@ function handleToggle() {
|
||||
function handlePickFromProvider(provider: MetadataProviderKey) {
|
||||
emit('pickFromProvider', props.field.key, provider)
|
||||
}
|
||||
|
||||
function toggleCurrentExpanded() {
|
||||
isCurrentExpanded.value = !isCurrentExpanded.value
|
||||
}
|
||||
|
||||
function toggleCandidateExpanded() {
|
||||
isCandidateExpanded.value = !isCandidateExpanded.value
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.field.key, props.field.currentDisplay, props.field.candidateDisplay],
|
||||
() => {
|
||||
isCurrentExpanded.value = false
|
||||
isCandidateExpanded.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -176,9 +221,15 @@ function handlePickFromProvider(provider: MetadataProviderKey) {
|
||||
:class="!field.hasDiff ? 'bg-muted/30 opacity-50' : field.isPicked ? 'bg-muted/30 opacity-40' : 'bg-background ring-1 ring-border'"
|
||||
>
|
||||
<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/60 italic' : 'text-foreground'">
|
||||
<p
|
||||
class="wrap-break-word leading-snug text-sm w-full"
|
||||
:class="[!field.currentDisplay ? 'text-muted-foreground/60 italic' : 'text-foreground', currentTextClass]"
|
||||
>
|
||||
{{ field.currentDisplay || 'empty' }}
|
||||
</p>
|
||||
<button v-if="canClampCurrent" class="mt-1 text-[10px] font-medium text-primary hover:underline" @click="toggleCurrentExpanded">
|
||||
{{ isCurrentExpanded ? 'Show less' : 'Show more' }}
|
||||
</button>
|
||||
<!-- Badge when value is picked from a different provider than the active tab -->
|
||||
<span
|
||||
v-if="field.isPicked && !field.pickedFromActive && field.pickedProvider"
|
||||
@@ -262,10 +313,17 @@ function handlePickFromProvider(provider: MetadataProviderKey) {
|
||||
<p
|
||||
v-else
|
||||
class="wrap-break-word leading-snug text-sm w-full"
|
||||
:class="field.isPicked && field.pickedFromActive ? 'text-primary font-medium' : 'text-muted-foreground'"
|
||||
:class="[field.isPicked && field.pickedFromActive ? 'text-primary font-medium' : 'text-muted-foreground', candidateTextClass]"
|
||||
>
|
||||
{{ field.candidateDisplay }}
|
||||
</p>
|
||||
<button
|
||||
v-if="canClampCandidate && field.key !== 'sourceUrl'"
|
||||
class="mt-1 text-[10px] font-medium text-primary hover:underline"
|
||||
@click="toggleCandidateExpanded"
|
||||
>
|
||||
{{ isCandidateExpanded ? 'Show less' : 'Show more' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,20 +10,24 @@ const props = defineProps<{
|
||||
providers: MetadataProviderInfo[]
|
||||
}>()
|
||||
|
||||
defineEmits<{ select: [MetadataCandidate] }>()
|
||||
const emit = defineEmits<{ select: [MetadataCandidate] }>()
|
||||
|
||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
||||
|
||||
const providerLabel = computed(() => getProviderLabel(props.candidate.provider, props.providers))
|
||||
|
||||
function handleSelect() {
|
||||
emit('select', props.candidate)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="group relative flex flex-row gap-3 p-2.5 rounded-xl border text-left transition-all active:scale-[0.98] overflow-hidden w-full border-border/60 bg-card hover:border-border hover:shadow-md hover:-translate-y-px"
|
||||
@click="$emit('select', candidate)"
|
||||
class="group relative flex flex-row gap-3.5 p-3 rounded-2xl border text-left transition-all active:scale-[0.98] overflow-hidden w-full border-border/60 bg-card hover:border-border hover:shadow-md hover:-translate-y-px"
|
||||
@click="handleSelect"
|
||||
>
|
||||
<!-- Cover -->
|
||||
<span class="relative shrink-0 rounded-lg overflow-hidden bg-muted block shadow-sm" :style="{ width: '64px', aspectRatio: coverAspectRatio }">
|
||||
<span class="relative shrink-0 rounded-lg overflow-hidden bg-muted block shadow-sm" :style="{ width: '88px', aspectRatio: coverAspectRatio }">
|
||||
<img
|
||||
v-if="candidate.coverUrl"
|
||||
:src="candidate.coverUrl"
|
||||
@@ -32,16 +36,19 @@ const providerLabel = computed(() => getProviderLabel(props.candidate.provider,
|
||||
@error="hideOnError"
|
||||
/>
|
||||
<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" />
|
||||
<BookOpen class="size-6 text-muted-foreground/25" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- Info -->
|
||||
<span class="flex-1 min-w-0 flex flex-col justify-center gap-1 py-0.5">
|
||||
<span class="flex-1 min-w-0 flex flex-col justify-center gap-1.5 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">
|
||||
<span v-if="candidate.authors?.length" class="text-xs text-muted-foreground line-clamp-1 block leading-tight">
|
||||
{{ candidate.authors.join(', ') }}
|
||||
</span>
|
||||
<span v-if="candidate.subtitle" class="text-xs text-muted-foreground line-clamp-2 leading-snug">
|
||||
{{ candidate.subtitle }}
|
||||
</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 }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { X, Sparkles, ChevronRight } from 'lucide-vue-next'
|
||||
import { X, Sparkles } from 'lucide-vue-next'
|
||||
import { isAudioFormat } from '@projectx/types'
|
||||
import type { BookDetail, MetadataCandidate, MetadataSource } from '@projectx/types'
|
||||
import { useMetadataSearch } from '../../../composables/useMetadataSearch'
|
||||
@@ -57,11 +57,19 @@ const {
|
||||
|
||||
const view = ref<'search' | 'diff'>('search')
|
||||
const selectedCandidate = ref<MetadataCandidate | null>(null)
|
||||
const drawerTitle = computed(() => (view.value === 'search' ? 'Search Metadata' : 'Compare Metadata'))
|
||||
const drawerSubtitle = computed(() =>
|
||||
view.value === 'search' ? 'Find the best provider match for this book.' : 'Review differences and apply only what you want.',
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleSearch(params: { title: string; author: string; isbn: string }) {
|
||||
const isAudiobook = props.book.files.some((f) => f.format != null && isAudioFormat(f.format))
|
||||
selectedCandidate.value = null
|
||||
@@ -88,7 +96,7 @@ function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) {
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 z-50 flex">
|
||||
<!-- Backdrop -->
|
||||
<div class="hidden sm:block flex-1 bg-black/50 backdrop-blur-sm" @click="$emit('close')" />
|
||||
<div class="hidden sm:block flex-1 bg-black/50 backdrop-blur-sm" @click="handleClose" />
|
||||
|
||||
<!-- Drawer panel -->
|
||||
<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">
|
||||
@@ -101,7 +109,7 @@ function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) {
|
||||
<!-- 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-all hover:scale-110"
|
||||
@click="$emit('close')"
|
||||
@click="handleClose"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</button>
|
||||
@@ -111,9 +119,10 @@ function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) {
|
||||
<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>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold">{{ drawerTitle }}</p>
|
||||
<p class="text-xs text-muted-foreground line-clamp-1">{{ drawerSubtitle }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step indicator -->
|
||||
<div class="ml-auto flex items-center gap-1 shrink-0">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, ref } from 'vue'
|
||||
import { Search, BookOpen, Loader2 } from 'lucide-vue-next'
|
||||
import { computed, inject, reactive, ref, watch } from 'vue'
|
||||
import { Search, BookOpen, Loader2, PencilLine } from 'lucide-vue-next'
|
||||
import type { MetadataCandidate, MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
|
||||
import MetadataResultCard from './MetadataResultCard.vue'
|
||||
import { providerActivePillStyle } from '../../../lib/metadata-fetch'
|
||||
@@ -30,18 +30,55 @@ const form = reactive({
|
||||
author: props.searchDefaults.author ?? '',
|
||||
isbn: props.searchDefaults.isbn ?? '',
|
||||
})
|
||||
const isFormCollapsed = ref(false)
|
||||
|
||||
const canSearch = computed(() => !!(form.title.trim() || form.isbn.trim()))
|
||||
const searchSummary = computed(() => {
|
||||
const parts = [form.title.trim(), form.author.trim(), form.isbn.trim()].filter(Boolean)
|
||||
return parts.length ? parts.join(' - ') : 'Untitled query'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.hasSearched,
|
||||
(hasSearched) => {
|
||||
if (hasSearched && !props.isStreaming) {
|
||||
isFormCollapsed.value = true
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function runSearch() {
|
||||
if (canSearch.value) emit('search', { ...form })
|
||||
if (!canSearch.value) return
|
||||
isFormCollapsed.value = true
|
||||
emit('search', {
|
||||
title: form.title.trim(),
|
||||
author: form.author.trim(),
|
||||
isbn: form.isbn.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
function expandSearchForm() {
|
||||
isFormCollapsed.value = false
|
||||
}
|
||||
|
||||
function handleClearFilter() {
|
||||
emit('clearFilter')
|
||||
}
|
||||
|
||||
function handleToggleProvider(provider: MetadataProviderKey) {
|
||||
emit('toggleProvider', provider)
|
||||
}
|
||||
|
||||
function handleSelectCandidate(candidate: MetadataCandidate) {
|
||||
emit('select', candidate)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Search form card -->
|
||||
<div class="px-4 pt-4 pb-3 shrink-0">
|
||||
<div v-if="!isFormCollapsed" 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">
|
||||
@@ -80,6 +117,24 @@ function runSearch() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-4 pt-4 pb-3 shrink-0">
|
||||
<div class="rounded-xl border border-border bg-card px-3 py-2.5 shadow-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<Search class="size-3.5 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">Active query</p>
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ searchSummary }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="h-7 px-2.5 rounded-lg border border-border bg-background text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-all active:scale-95 flex items-center gap-1.5 shrink-0"
|
||||
@click="expandSearchForm"
|
||||
>
|
||||
<PencilLine class="size-3.5" />
|
||||
Edit
|
||||
</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">
|
||||
@@ -90,7 +145,7 @@ function runSearch() {
|
||||
? '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')"
|
||||
@click="handleClearFilter"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
@@ -104,7 +159,7 @@ function runSearch() {
|
||||
: '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)"
|
||||
@click="handleToggleProvider(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
<span
|
||||
@@ -120,9 +175,9 @@ function runSearch() {
|
||||
<!-- Results (scrollable) -->
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<!-- 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-if="isStreaming && !filteredResults.length" class="grid grid-cols-1 lg: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', aspectRatio: coverAspectRatio }" />
|
||||
<div class="rounded-lg bg-muted shrink-0" :style="{ width: '88px', aspectRatio: coverAspectRatio }" />
|
||||
<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" />
|
||||
@@ -148,13 +203,13 @@ function runSearch() {
|
||||
</div>
|
||||
|
||||
<!-- Results grid -->
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<MetadataResultCard
|
||||
v-for="(candidate, i) in filteredResults"
|
||||
:key="`${candidate.provider}-${candidate.providerId}-${i}`"
|
||||
:candidate="candidate"
|
||||
:providers="providers"
|
||||
@select="$emit('select', $event)"
|
||||
@select="handleSelectCandidate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ const fontFamilies = [
|
||||
|
||||
const stepperButtonClass = 'size-8 rounded-lg border border-border text-lg font-light text-foreground transition-colors hover:bg-muted'
|
||||
const stepperGroupClass = 'flex min-w-[11rem] items-center justify-end gap-2'
|
||||
const stepperValueClass = 'w-[4.75rem] text-center text-sm font-mono font-medium tabular-nums text-foreground'
|
||||
const stepperValueClass = 'w-[4rem] text-center text-sm font-mono font-medium tabular-nums text-foreground'
|
||||
|
||||
function step(field: keyof ReaderState, delta: number, min: number, max: number, precision = 0) {
|
||||
const current = props.state[field] as number
|
||||
@@ -93,7 +93,7 @@ onMounted(() => {
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="flex h-[2.125rem] items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors"
|
||||
class="flex h-8.5 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
activeTab === tab.id
|
||||
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
|
||||
@@ -107,7 +107,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="contentRef" class="overflow-y-auto px-4 py-4 space-y-6" @scroll="onContentScroll">
|
||||
<div ref="contentRef" class="overflow-y-auto p-5.5 space-y-6" @scroll="onContentScroll">
|
||||
<template v-if="activeTab === 'appearance'">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
@@ -283,7 +283,7 @@ onMounted(() => {
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1 pr-3">
|
||||
<p class="text-sm font-medium leading-tight">Max width</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Range: 400-1600px</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Range: 400-1600</p>
|
||||
</div>
|
||||
<div :class="stepperGroupClass">
|
||||
<button :class="stepperButtonClass" @click="step('maxInlineSize', -40, 400, 1600)">−</button>
|
||||
|
||||
Reference in New Issue
Block a user