mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): add provider reservoir and drag-to-assign for fields
This commit is contained in:
@@ -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>
|
||||
+26
-14
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user