feat(metadata): add provider reservoir and drag-to-assign for fields

This commit is contained in:
neon
2026-03-01 14:41:28 -06:00
parent f60584cf16
commit 6c51441215
6 changed files with 365 additions and 42 deletions
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { computed } from 'vue'
import { X, ChevronUp, ChevronDown } from 'lucide-vue-next'
import type { FieldPreference, MetadataField, MetadataProviderKey, ProviderStatus } from '@projectx/types'
import MergeStrategyPicker from './MergeStrategyPicker.vue'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
const FIELD_LABELS: Record<MetadataField, string> = {
title: 'Title',
subtitle: 'Subtitle',
description: 'Description',
cover: 'Cover',
authors: 'Authors',
publisher: 'Publisher',
publishedYear: 'Published year',
language: 'Language',
pageCount: 'Page count',
seriesName: 'Series name',
seriesIndex: 'Series index',
genres: 'Genres',
}
const props = defineProps<{
field: MetadataField
preference: FieldPreference
statuses: ProviderStatus[]
}>()
const emit = defineEmits<{
change: [pref: FieldPreference]
close: []
}>()
function isAssigned(key: MetadataProviderKey) {
return props.preference.providers.includes(key)
}
function isUsable(status: ProviderStatus) {
return status.enabled && status.configured
}
function toggleProvider(key: MetadataProviderKey) {
const providers = [...props.preference.providers]
const idx = providers.indexOf(key)
if (idx >= 0) {
providers.splice(idx, 1)
} else {
providers.push(key)
}
emit('change', { ...props.preference, providers })
}
function moveUp(key: MetadataProviderKey) {
const providers = [...props.preference.providers]
const idx = providers.indexOf(key)
if (idx <= 0) return
;[providers[idx - 1], providers[idx]] = [providers[idx], providers[idx - 1]]
emit('change', { ...props.preference, providers })
}
function moveDown(key: MetadataProviderKey) {
const providers = [...props.preference.providers]
const idx = providers.indexOf(key)
if (idx < 0 || idx >= providers.length - 1) return
;[providers[idx], providers[idx + 1]] = [providers[idx + 1], providers[idx]]
emit('change', { ...props.preference, providers })
}
// Assigned providers in order first, then unassigned
const sortedStatuses = computed(() => {
const assigned = props.preference.providers.map((k) => props.statuses.find((s) => s.key === k)).filter(Boolean) as ProviderStatus[]
const unassigned = props.statuses.filter((s) => !props.preference.providers.includes(s.key as MetadataProviderKey))
return [...assigned, ...unassigned]
})
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex flex-col justify-end">
<div class="absolute inset-0 bg-black/50" @click="$emit('close')" />
<div class="relative bg-card border-t border-border rounded-t-2xl max-h-[85vh] flex flex-col z-10">
<div class="flex items-center justify-between px-4 py-3.5 border-b border-border shrink-0">
<div>
<p class="text-sm font-semibold text-foreground">{{ FIELD_LABELS[field] }}</p>
<p class="text-xs text-muted-foreground mt-0.5">Tap providers to assign, use arrows to reorder</p>
</div>
<button class="h-8 w-8 flex items-center justify-center rounded-full hover:bg-muted transition-colors" @click="$emit('close')">
<X :size="16" />
</button>
</div>
<div class="overflow-y-auto flex-1 px-4 py-4 space-y-5">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
:checked="preference.enabled"
class="h-4 w-4 rounded border-input accent-primary"
@change="$emit('change', { ...preference, enabled: ($event.target as HTMLInputElement).checked })"
/>
<span class="text-sm text-foreground">Enable this field during refresh</span>
</label>
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2.5">Providers</p>
<div class="space-y-1.5">
<div
v-for="status in sortedStatuses"
:key="status.key"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors"
:class="isAssigned(status.key as MetadataProviderKey) ? 'border-border bg-muted/40' : 'border-transparent'"
>
<input
type="checkbox"
:checked="isAssigned(status.key as MetadataProviderKey)"
:disabled="!isUsable(status) || !preference.enabled"
class="h-4 w-4 rounded border-input accent-primary shrink-0"
@change="toggleProvider(status.key as MetadataProviderKey)"
/>
<div class="flex-1 flex items-center gap-2" :class="!isUsable(status) ? 'opacity-50' : ''">
<span class="text-xs font-medium px-2 py-0.5 rounded" :style="providerChipStyle(status.key, !isUsable(status))">
{{ PROVIDER_SHORT_LABELS[status.key] ?? status.key }}
</span>
<span v-if="!status.enabled" class="text-xs text-muted-foreground">disabled</span>
<span v-else-if="!status.configured" class="text-xs text-muted-foreground">not configured</span>
</div>
<div v-if="isAssigned(status.key as MetadataProviderKey)" class="flex items-center gap-1 shrink-0">
<span class="text-xs tabular-nums text-muted-foreground w-4 text-center">
{{ preference.providers.indexOf(status.key as MetadataProviderKey) + 1 }}
</span>
<button
:disabled="preference.providers.indexOf(status.key as MetadataProviderKey) === 0"
class="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 transition-colors"
@click="moveUp(status.key as MetadataProviderKey)"
>
<ChevronUp :size="15" />
</button>
<button
:disabled="preference.providers.indexOf(status.key as MetadataProviderKey) === preference.providers.length - 1"
class="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 transition-colors"
@click="moveDown(status.key as MetadataProviderKey)"
>
<ChevronDown :size="15" />
</button>
</div>
</div>
</div>
</div>
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2.5">Merge Strategy</p>
<MergeStrategyPicker
:model-value="preference.mergeStrategy"
:disabled="!preference.enabled"
class="w-full"
@update:model-value="$emit('change', { ...preference, mergeStrategy: $event })"
/>
</div>
</div>
<div class="px-4 pb-6 pt-2 shrink-0">
<button
class="w-full h-10 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity"
@click="$emit('close')"
>
Done
</button>
</div>
</div>
</div>
</Teleport>
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { FieldPreference, MetadataFetchPreferences, MetadataField, ProviderStatus } from '@projectx/types'
import FieldGroupSection from './FieldGroupSection.vue'
import ProviderReservoir from './ProviderReservoir.vue'
defineProps<{
preferences: MetadataFetchPreferences
@@ -25,19 +26,30 @@ const GROUPS: { label: string; fields: MetadataField[] }[] = [
</script>
<template>
<div class="divide-y divide-border/60">
<FieldGroupSection
v-for="group in GROUPS"
:key="group.label"
:label="group.label"
:fields="group.fields"
:preferences="preferences.fields"
:statuses="statuses"
:overridden-fields="overriddenFields"
:saving-field="savingField"
:library-id="libraryId"
@change="(f, p) => emit('change', f, p)"
@revert="(f) => emit('revert', f)"
/>
<div>
<ProviderReservoir :statuses="statuses" />
<!-- Column headers (desktop only) -->
<div class="hidden sm:flex items-center gap-2 px-4 py-2 border-b border-border/60 bg-muted/10">
<span class="w-36 shrink-0 text-xs text-muted-foreground">Field</span>
<span class="flex-1 text-xs text-muted-foreground">Providers</span>
<span class="shrink-0 text-xs text-muted-foreground pr-2">Strategy</span>
</div>
<div class="divide-y divide-border/60">
<FieldGroupSection
v-for="group in GROUPS"
:key="group.label"
:label="group.label"
:fields="group.fields"
:preferences="preferences.fields"
:statuses="statuses"
:overridden-fields="overriddenFields"
:saving-field="savingField"
:library-id="libraryId"
@change="(f, p) => emit('change', f, p)"
@revert="(f) => emit('revert', f)"
/>
</div>
</div>
</template>
@@ -1,10 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RotateCcw } from 'lucide-vue-next'
import type { FieldPreference, MetadataField, ProviderStatus } from '@projectx/types'
import { computed, ref } from 'vue'
import { AlertTriangle, RotateCcw, Settings2 } from 'lucide-vue-next'
import type { FieldPreference, MetadataField, MetadataProviderKey, ProviderStatus } from '@projectx/types'
import MergeStrategyPicker from './MergeStrategyPicker.vue'
import ProviderChipList from './ProviderChipList.vue'
import FieldConfigSheet from './FieldConfigSheet.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
const RESERVOIR_TYPE = 'application/x-provider-key'
const props = defineProps<{
field: MetadataField
@@ -35,14 +39,53 @@ const FIELD_LABELS: Record<MetadataField, string> = {
}
const label = computed(() => FIELD_LABELS[props.field] ?? props.field)
const noProviders = computed(() => props.preference.enabled && props.preference.providers.length === 0)
const sheetOpen = ref(false)
const isDragOver = ref(false)
function update(patch: Partial<FieldPreference>) {
emit('change', props.field, { ...props.preference, ...patch })
}
function onSheetChange(pref: FieldPreference) {
emit('change', props.field, pref)
}
function onDragOver(e: DragEvent) {
if (!e.dataTransfer?.types.includes(RESERVOIR_TYPE)) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
isDragOver.value = true
}
function onDragLeave(e: DragEvent) {
const row = e.currentTarget as Element
if (e.relatedTarget instanceof Node && row.contains(e.relatedTarget)) return
isDragOver.value = false
}
function onDrop(e: DragEvent) {
isDragOver.value = false
const key = e.dataTransfer?.getData(RESERVOIR_TYPE) as MetadataProviderKey | undefined
if (!key) return
if (props.preference.providers.includes(key)) return
update({ providers: [...props.preference.providers, key] })
}
</script>
<template>
<div class="flex flex-col sm:flex-row sm:items-center gap-2 px-4 py-3 transition-colors">
<div
class="flex flex-col sm:flex-row sm:items-center gap-2 px-4 py-3 transition-colors relative"
:class="isDragOver ? 'bg-primary/5 outline outline-1 outline-primary/25 outline-offset-[-1px]' : ''"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<!-- Drop hint overlay -->
<div v-if="isDragOver" class="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<span class="text-xs text-primary font-medium bg-card border border-primary/30 px-2.5 py-1 rounded-full shadow-sm"> Add to {{ label }} </span>
</div>
<!-- Enable toggle + label -->
<div class="flex items-center gap-2 sm:w-36 shrink-0">
<input
@@ -55,8 +98,8 @@ function update(patch: Partial<FieldPreference>) {
<span class="text-sm text-foreground font-medium truncate">{{ label }}</span>
</div>
<!-- Provider chip list -->
<div class="flex-1 min-w-0">
<!-- Provider chip list (desktop) -->
<div class="flex-1 min-w-0 hidden sm:block">
<ProviderChipList
:providers="preference.providers"
:statuses="statuses"
@@ -65,8 +108,40 @@ function update(patch: Partial<FieldPreference>) {
/>
</div>
<!-- Merge strategy + inherited badge + revert -->
<div class="flex items-center gap-2 shrink-0">
<!-- Mobile: chip preview + configure button -->
<div class="flex items-center gap-2 sm:hidden">
<div class="flex flex-wrap gap-1 flex-1 min-w-0">
<span v-for="key in preference.providers" :key="key" class="text-xs px-1.5 py-0.5 rounded font-medium" :style="providerChipStyle(key)">
{{ PROVIDER_SHORT_LABELS[key] ?? key }}
</span>
<span v-if="preference.providers.length === 0 && !preference.enabled" class="text-xs text-muted-foreground/50 italic">none</span>
<span v-if="noProviders" class="flex items-center gap-1 text-xs text-amber-500 font-medium">
<AlertTriangle :size="11" />
No providers
</span>
</div>
<button
:disabled="saving"
class="shrink-0 flex items-center gap-1.5 h-7 px-2.5 rounded border border-border bg-background text-xs text-foreground hover:bg-muted transition-colors disabled:opacity-40"
@click="sheetOpen = true"
>
<Settings2 :size="12" />
Configure
</button>
</div>
<!-- Merge strategy + badges + revert (desktop) -->
<div class="hidden sm:flex items-center gap-2 shrink-0">
<Tooltip v-if="noProviders">
<TooltipTrigger as-child>
<span class="flex items-center gap-1 text-xs text-amber-500 font-medium shrink-0">
<AlertTriangle :size="12" />
No providers
</span>
</TooltipTrigger>
<TooltipContent>Field is enabled but has no providers - it will be skipped during refresh</TooltipContent>
</Tooltip>
<MergeStrategyPicker
:model-value="preference.mergeStrategy"
:disabled="!preference.enabled || saving"
@@ -85,7 +160,7 @@ function update(patch: Partial<FieldPreference>) {
<TooltipTrigger as-child>
<button
:disabled="saving"
class="flex items-center gap-1 h-6 px-1.5 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
class="flex items-center h-6 px-1.5 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
@click="$emit('revert', field)"
>
<RotateCcw :size="11" />
@@ -94,5 +169,14 @@ function update(patch: Partial<FieldPreference>) {
<TooltipContent>Revert to global default</TooltipContent>
</Tooltip>
</div>
<FieldConfigSheet
v-if="sheetOpen"
:field="field"
:preference="preference"
:statuses="statuses"
@change="onSheetChange"
@close="sheetOpen = false"
/>
</div>
</template>
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { GripVertical } from 'lucide-vue-next'
import { GripVertical, X } from 'lucide-vue-next'
import type { MetadataProviderKey, ProviderStatus } from '@projectx/types'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
const REORDER_TYPE = 'application/x-field-chip-index'
const props = defineProps<{
providers: MetadataProviderKey[]
statuses: ProviderStatus[]
@@ -27,11 +29,15 @@ const dragOverIndex = ref<number | null>(null)
function onDragStart(index: number, e: DragEvent) {
draggingIndex.value = index
e.dataTransfer!.effectAllowed = 'move'
e.dataTransfer!.setData(REORDER_TYPE, String(index))
e.stopPropagation()
}
function onDragOver(index: number, e: DragEvent) {
if (!e.dataTransfer?.types.includes(REORDER_TYPE)) return
e.preventDefault()
e.dataTransfer!.dropEffect = 'move'
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
if (draggingIndex.value !== null && draggingIndex.value !== index) {
dragOverIndex.value = index
}
@@ -41,7 +47,9 @@ function onDragLeave(index: number) {
if (dragOverIndex.value === index) dragOverIndex.value = null
}
function onDrop(targetIndex: number) {
function onDrop(targetIndex: number, e: DragEvent) {
if (!e.dataTransfer?.types.includes(REORDER_TYPE)) return
e.stopPropagation()
if (draggingIndex.value === null || draggingIndex.value === targetIndex) return
const updated = [...props.providers]
const [moved] = updated.splice(draggingIndex.value, 1)
@@ -56,39 +64,49 @@ function onDragEnd() {
dragOverIndex.value = null
}
function chipTitle(key: MetadataProviderKey) {
const s = statusFor(key)
if (!s) return key
if (!s.enabled) return `${s.label} (disabled)`
if (!s.configured) return `${s.label} (API key required)`
return s.label
function removeProvider(index: number) {
const updated = [...props.providers]
updated.splice(index, 1)
emit('update:providers', updated)
}
</script>
<template>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-1.5 min-h-[26px] items-center">
<div
v-for="(key, index) in providers"
:key="key"
:draggable="!disabled"
:title="chipTitle(key)"
class="flex items-center gap-1 h-6 px-1.5 rounded text-xs font-medium select-none"
:title="statusFor(key)?.label ?? key"
class="flex items-center gap-1 h-6 pl-1.5 pr-1 rounded text-xs font-medium select-none"
:style="providerChipStyle(key, !isUsable(key))"
:class="[
!disabled ? 'cursor-grab active:cursor-grabbing' : 'cursor-default',
draggingIndex === index ? 'opacity-30 scale-95' : '',
dragOverIndex === index ? 'ring-2 ring-white/70 scale-110 brightness-125' : '',
dragOverIndex === index ? 'ring-2 ring-white/60 scale-105' : '',
]"
style="transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease"
style="transition: transform 0.12s ease"
@dragstart="onDragStart(index, $event)"
@dragover="onDragOver(index, $event)"
@dragleave="onDragLeave(index)"
@drop="onDrop(index)"
@drop="onDrop(index, $event)"
@dragend="onDragEnd"
>
<GripVertical v-if="!disabled" :size="10" class="opacity-60 shrink-0" />
<span class="opacity-80 tabular-nums leading-none">{{ index + 1 }}</span>
{{ PROVIDER_SHORT_LABELS[key] ?? key }}
<GripVertical v-if="!disabled" :size="10" class="opacity-50 shrink-0" />
<span class="opacity-70 tabular-nums leading-none">{{ index + 1 }}</span>
<span>{{ PROVIDER_SHORT_LABELS[key] ?? key }}</span>
<button
v-if="!disabled"
class="ml-0.5 h-4 w-4 flex items-center justify-center rounded-sm opacity-60 hover:opacity-100 hover:bg-white/20 transition-opacity"
@click.stop="removeProvider(index)"
@mousedown.stop
>
<X :size="12" :stroke-width="3" />
</button>
</div>
<span v-if="providers.length === 0 && !disabled" class="text-xs text-muted-foreground/40 italic h-6 flex items-center px-1">
drag a provider here
</span>
</div>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { ProviderStatus } from '@projectx/types'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
defineProps<{
statuses: ProviderStatus[]
}>()
function onDragStart(key: string, e: DragEvent) {
e.dataTransfer!.effectAllowed = 'copy'
e.dataTransfer!.setData('application/x-provider-key', key)
}
</script>
<template>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 border-b border-border bg-muted/30">
<div class="flex flex-wrap gap-2">
<div
v-for="status in statuses"
:key="status.key"
:draggable="status.enabled && status.configured"
:title="
!status.enabled
? `${status.label} - disabled`
: !status.configured
? `${status.label} - not configured`
: `Drag to assign ${status.label} to a field`
"
class="flex items-center gap-1.5 h-7 px-2.5 rounded text-xs font-medium select-none transition-transform"
:style="providerChipStyle(status.key, !(status.enabled && status.configured))"
:class="status.enabled && status.configured ? 'cursor-grab active:cursor-grabbing active:scale-95' : 'cursor-not-allowed'"
@dragstart="onDragStart(status.key, $event)"
>
{{ PROVIDER_SHORT_LABELS[status.key] ?? status.key }}
</div>
</div>
<span class="text-xs text-muted-foreground/60 hidden sm:block">drag onto any field row to assign</span>
</div>
</template>
@@ -13,7 +13,6 @@ const DEFAULT_PROVIDER_ORDER: MetadataProviderKey[] = [
MetadataProviderKey.GOOGLE,
MetadataProviderKey.AMAZON,
MetadataProviderKey.GOODREADS,
MetadataProviderKey.HARDCOVER,
MetadataProviderKey.OPEN_LIBRARY,
];