feat(metadata): add preferences, provider priority, and cover updates

This commit is contained in:
neon
2026-02-26 11:42:47 -06:00
parent 6cbd6dd32d
commit f3aa1a05ad
47 changed files with 2391 additions and 206 deletions
@@ -2,11 +2,24 @@
import type { BookCard, BookFileRef } from '@projectx/types'
import { FORMAT_TO_GROUP } from '@projectx/types'
import { bookCoverStyle } from '../lib/book-cover'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, Check, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2, TriangleAlert } from 'lucide-vue-next'
import {
BookOpen,
Check,
ExternalLink,
FolderPlus,
Loader2,
MoreHorizontal,
PanelRight,
Pencil,
RefreshCw,
Trash2,
TriangleAlert,
} from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useCoverVersions } from '../composables/useCoverVersions'
import { useRefreshMetadata } from '../composables/useRefreshMetadata'
const router = useRouter()
@@ -37,10 +50,17 @@ const extraFiles = computed(() => readableFiles.value.filter((f) => f !== primar
const { coverUrl } = useCoverVersions()
const coverSrc = computed(() => coverUrl(props.book.id))
const { refreshing, refreshWithFeedback } = useRefreshMetadata()
const coverLoaded = ref(false)
const coverFailed = ref(false)
const isMissing = computed(() => props.book.status === 'missing')
watch(coverSrc, () => {
coverLoaded.value = false
coverFailed.value = false
})
function openFile(file: BookFileRef) {
router.push({
name: 'reader',
@@ -117,6 +137,13 @@ function openFile(file: BookFileRef) {
</p>
</div>
<!-- Refresh spinner overlay -->
<Transition name="fade">
<div v-if="refreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/30 backdrop-blur-[1.5px]">
<Loader2 class="size-[32cqi] animate-spin text-white drop-shadow-lg" />
</div>
</Transition>
<!-- Hover overlay -->
<div
v-if="!selectionMode"
@@ -174,6 +201,11 @@ function openFile(file: BookFileRef) {
<Pencil class="size-4 mr-2" />
Edit Metadata
</DropdownMenuItem>
<DropdownMenuItem :disabled="refreshing" @click="refreshWithFeedback(book.id)">
<Loader2 v-if="refreshing" class="size-4 mr-2 animate-spin" />
<RefreshCw v-else class="size-4 mr-2" />
Refresh Metadata
</DropdownMenuItem>
<DropdownMenuItem @click="emit('action', 'add-to-collection')">
<FolderPlus class="size-4 mr-2" />
Add to Collection
@@ -190,3 +222,14 @@ function openFile(file: BookFileRef) {
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -2,11 +2,25 @@
import type { BookCard, BookFileRef } from '@projectx/types'
import { bookCoverStyle } from '../lib/book-cover'
import { api } from '@/lib/api'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, Check, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Star, Trash2, TriangleAlert } from 'lucide-vue-next'
import {
BookOpen,
Check,
ExternalLink,
FolderPlus,
Loader2,
MoreHorizontal,
PanelRight,
Pencil,
RefreshCw,
Star,
Trash2,
TriangleAlert,
} from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useCoverVersions } from '../composables/useCoverVersions'
import { useRefreshMetadata } from '../composables/useRefreshMetadata'
const router = useRouter()
@@ -65,6 +79,13 @@ async function setRating(star: number) {
const { coverUrl } = useCoverVersions()
const coverSrc = computed(() => coverUrl(props.book.id))
watch(coverSrc, () => {
coverLoaded.value = false
coverFailed.value = false
})
const { refreshing, refreshWithFeedback } = useRefreshMetadata()
function openFile(file: BookFileRef) {
router.push({
name: 'reader',
@@ -194,6 +215,11 @@ function openFile(file: BookFileRef) {
<Pencil class="size-4 mr-2" />
Edit Metadata
</DropdownMenuItem>
<DropdownMenuItem :disabled="refreshing" @click="refreshWithFeedback(book.id)">
<Loader2 v-if="refreshing" class="size-4 mr-2 animate-spin" />
<RefreshCw v-else class="size-4 mr-2" />
Refresh Metadata
</DropdownMenuItem>
<DropdownMenuItem @click="emit('action', 'add-to-collection')">
<FolderPlus class="size-4 mr-2" />
Add to Collection
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Sparkles, Star } from 'lucide-vue-next'
import { Loader2, RefreshCw, Sparkles, Star } from 'lucide-vue-next'
import type { BookDetail } from '@projectx/types'
import ChipInput from '@/components/ui/ChipInput.vue'
import CoverEditorPanel from './CoverEditorPanel.vue'
@@ -9,6 +9,7 @@ import type { MetadataPatch } from '../../../composables/useMetadataDiff'
import { useMetadataEditor } from '../../../composables/useMetadataEditor'
import { useAuthorSearch } from '../../../composables/useAuthorSearch'
import { useGenreSearch } from '../../../composables/useTagSearch'
import { useRefreshMetadata } from '../../../composables/useRefreshMetadata'
const props = defineProps<{ book: BookDetail }>()
const emit = defineEmits<{ saved: [BookDetail]; coverChanged: ['extracted' | 'custom' | null] }>()
@@ -74,6 +75,25 @@ function handleApply({ formPatch, coverUrl }: { formPatch: MetadataPatch; coverU
Object.assign(form, formPatch)
if (coverUrl) coverPanel.value?.setUrl(coverUrl)
}
const { refreshing: autoFilling, previewRefresh } = useRefreshMetadata()
async function autoFill() {
const preview = await previewRefresh(props.book.id)
if (!preview) return
if (preview.title != null) form.title = preview.title
if (preview.subtitle != null) form.subtitle = preview.subtitle
if (preview.description != null) form.description = preview.description
if (preview.authors?.length) form.authors = preview.authors
if (preview.genres?.length) form.genres = preview.genres
if (preview.publisher != null) form.publisher = preview.publisher
if (preview.publishedYear != null) form.publishedYear = preview.publishedYear
if (preview.language != null) form.language = preview.language
if (preview.pageCount != null) form.pageCount = preview.pageCount
if (preview.seriesName != null) form.seriesName = preview.seriesName
if (preview.seriesIndex != null) form.seriesIndex = preview.seriesIndex
if (preview.coverUrl) coverPanel.value?.setUrl(preview.coverUrl)
}
</script>
<template>
@@ -91,7 +111,17 @@ function handleApply({ formPatch, coverUrl }: { formPatch: MetadataPatch; coverU
<span v-else />
<div class="flex gap-2">
<button
class="flex items-center gap-1.5 h-8 px-3.5 rounded-lg bg-gradient-to-r from-violet-500 to-indigo-500 text-white text-sm font-medium hover:from-violet-600 hover:to-indigo-600 transition-all shadow-[0_2px_10px_rgba(139,92,246,0.4)] hover:shadow-[0_2px_14px_rgba(139,92,246,0.55)]"
class="auto-fill-btn flex items-center gap-1.5 h-8 px-3 rounded-lg text-sm font-medium transition-all disabled:opacity-40"
:disabled="autoFilling"
:title="autoFilling ? 'Fetching metadata...' : 'Auto-fill fields using your metadata preferences'"
@click="autoFill"
>
<Loader2 v-if="autoFilling" class="size-3.5 animate-spin" />
<RefreshCw v-else class="size-3.5" />
Auto-fill
</button>
<button
class="search-online-btn flex items-center gap-1.5 h-8 px-3.5 rounded-lg text-primary-foreground text-sm font-medium transition-all"
@click="searchOpen = true"
>
<Sparkles class="size-3.5" />
@@ -290,3 +320,26 @@ function handleApply({ formPatch, coverUrl }: { formPatch: MetadataPatch; coverU
<MetadataSearchDrawer v-if="searchOpen" :book="props.book" @close="searchOpen = false" @apply="handleApply" />
</template>
<style scoped>
.auto-fill-btn {
background: linear-gradient(to right, oklch(0.75 0.16 75), oklch(0.72 0.18 55));
color: oklch(0.2 0.04 75);
box-shadow: 0 2px 8px oklch(0.72 0.18 55 / 0.35);
}
.auto-fill-btn:hover {
filter: brightness(1.08);
box-shadow: 0 2px 12px oklch(0.72 0.18 55 / 0.5);
}
.auto-fill-btn:disabled {
filter: none;
}
.search-online-btn {
background: linear-gradient(to right, var(--primary), color-mix(in oklch, var(--primary) 65%, oklch(0.7 0.25 280)));
box-shadow: 0 2px 10px color-mix(in oklch, var(--primary) 45%, transparent);
}
.search-online-btn:hover {
filter: brightness(1.1);
box-shadow: 0 2px 14px color-mix(in oklch, var(--primary) 60%, transparent);
}
</style>
@@ -0,0 +1,59 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { BookDetail } from '@projectx/types'
import { useCoverVersions } from './useCoverVersions'
import { toast } from 'vue-sonner'
export interface MetadataRefreshPreview {
title?: string | null
subtitle?: string | null
description?: string | null
authors?: string[]
genres?: string[]
publisher?: string | null
publishedYear?: number | null
language?: string | null
pageCount?: number | null
seriesName?: string | null
seriesIndex?: number | null
coverUrl?: string
}
export function useRefreshMetadata() {
const refreshing = ref(false)
const { bumpVersion } = useCoverVersions()
async function callRefresh<T>(bookId: number, preview: boolean): Promise<T | null> {
refreshing.value = true
try {
const url = `/api/books/${bookId}/refresh-metadata${preview ? '?preview=true' : ''}`
const res = await api(url, { method: 'POST' })
if (!res.ok) return null
return (await res.json()) as T
} catch {
return null
} finally {
refreshing.value = false
}
}
async function refreshAndSave(bookId: number): Promise<BookDetail | null> {
return callRefresh<BookDetail>(bookId, false)
}
async function previewRefresh(bookId: number): Promise<MetadataRefreshPreview | null> {
return callRefresh<MetadataRefreshPreview>(bookId, true)
}
async function refreshWithFeedback(bookId: number): Promise<void> {
const updated = await refreshAndSave(bookId)
if (updated) {
bumpVersion(bookId)
toast.success('Metadata refreshed')
} else {
toast.error('Metadata refresh failed')
}
}
return { refreshing, refreshAndSave, previewRefresh, refreshWithFeedback }
}
+2 -33
View File
@@ -1,5 +1,7 @@
import type { MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
export { getProviderColor, providerBadgeStyle, providerActivePillStyle } from '@/lib/provider-colors'
export function getProviderLabel(provider: MetadataProviderKey, providers: MetadataProviderInfo[]): string {
return providers.find((p) => p.key === provider)?.label ?? provider
}
@@ -7,36 +9,3 @@ 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: '#06B6D4',
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)
}
@@ -19,6 +19,7 @@ import {
Tablet,
Wrench,
FolderPen,
DatabaseZap,
} from 'lucide-vue-next'
import { useSettingsDrawer } from '@/composables/useSettingsDrawer'
import { usePermissions } from '@/features/auth/composables/usePermissions'
@@ -38,6 +39,7 @@ import OidcSettings from './OidcSettings.vue'
import OpdsSettings from './OpdsSettings.vue'
import KoboSettings from './KoboSettings.vue'
import MaintenanceSettings from './MaintenanceSettings.vue'
import MetadataPreferencesSettings from './metadata-preferences/MetadataPreferencesSettings.vue'
const { isOpen, close } = useSettingsDrawer()
const { isSuperuser, userPermissions } = usePermissions()
@@ -60,6 +62,7 @@ type SectionId =
| 'oidc'
| 'file-naming'
| 'maintenance'
| 'metadata-preferences'
const navGroups = computed(() => {
const perms = userPermissions.value
@@ -92,6 +95,9 @@ const navGroups = computed(() => {
adminItems.push({ id: 'roles', label: 'Roles', icon: ShieldCheck, component: RolesPage })
adminItems.push({ id: 'permissions', label: 'Permissions', icon: KeyRound, component: PermissionsPage })
}
if (su || perms.includes('manage_metadata_config')) {
adminItems.push({ id: 'metadata-preferences', label: 'Metadata', icon: DatabaseZap, component: MetadataPreferencesSettings })
}
if (su || perms.includes('manage_app_settings')) {
adminItems.push({ id: 'oidc', label: 'OIDC / SSO', icon: LogIn, component: OidcSettings })
adminItems.push({ id: 'file-naming', label: 'File Naming', icon: FolderPen, component: FileNamingSettings })
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import type { FieldPreference, MetadataField, ProviderConfigurations } from '@projectx/types'
import { useLibraries } from '@/features/library/composables/useLibraries'
import ProviderConfigPanel from './components/ProviderConfigPanel.vue'
import GlobalPreferencePanel from './components/GlobalPreferencePanel.vue'
import LibraryPreferencePanel from './components/LibraryPreferencePanel.vue'
import { useProviderConfig } from './composables/useProviderConfig'
import { useMetadataPreferences } from './composables/useMetadataPreferences'
const { config, statuses, saving: savingProviders, fetchConfig, saveConfig } = useProviderConfig()
const { globalPrefs, libraryPrefs, savingGlobal, savingField, fetchGlobal, saveGlobal, fetchLibrary, saveFieldOverride, resetLibrary } =
useMetadataPreferences()
const { libraries, fetchLibraries } = useLibraries()
onMounted(async () => {
await Promise.all([fetchConfig(), fetchGlobal(), fetchLibraries()])
})
const fetchedLibraryIds = new Set<number>()
watch(libraries, async (libs) => {
const newLibs = libs.filter((lib) => !fetchedLibraryIds.has(lib.id))
newLibs.forEach((lib) => fetchedLibraryIds.add(lib.id))
await Promise.all(newLibs.map((lib) => fetchLibrary(lib.id)))
})
async function onFieldChange(libraryId: number, field: MetadataField, pref: FieldPreference | null) {
await saveFieldOverride(libraryId, field, pref)
}
async function onResetLibrary(libraryId: number) {
await resetLibrary(libraryId)
}
</script>
<template>
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-4xl mx-auto space-y-8">
<div>
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">Metadata Preferences</h2>
<p class="mt-1 text-sm text-muted-foreground">Control which providers supply each metadata field and how values are merged during a refresh.</p>
</div>
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Providers</p>
<ProviderConfigPanel
:config="config"
:statuses="statuses"
:saving="savingProviders"
@save="saveConfig($event as Partial<ProviderConfigurations>)"
/>
</div>
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">Field Preferences</p>
<p class="text-xs text-muted-foreground mb-3">
For each metadata field, choose which providers to use and in what order (drag to reorder - lower number = higher priority).
The merge strategy controls how a provider's value is applied: <span class="font-medium text-foreground">Fill missing</span> only writes when empty,
<span class="font-medium text-foreground">Overwrite if provided</span> writes whenever the provider returns a value,
<span class="font-medium text-foreground">Always overwrite</span> replaces the existing value unconditionally.
</p>
<div class="space-y-3">
<GlobalPreferencePanel :preferences="globalPrefs" :statuses="statuses" :saving="savingGlobal" @save="saveGlobal" />
<div v-if="libraries.length" class="space-y-2">
<p class="text-xs text-muted-foreground px-1">Per-library overrides - expand a library to customize individual fields. Fields not overridden inherit global defaults.</p>
<LibraryPreferencePanel
v-for="lib in libraries"
:key="lib.id"
:library-name="lib.name"
:library-prefs="libraryPrefs.get(lib.id) ?? null"
:statuses="statuses"
:saving-field="savingField"
@field-change="onFieldChange"
@reset="onResetLibrary"
/>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ChevronDown, ChevronRight } from 'lucide-vue-next'
import type { FieldPreference, MetadataField, ProviderStatus } from '@projectx/types'
import FieldRow from './FieldRow.vue'
defineProps<{
label: string
fields: MetadataField[]
preferences: Record<MetadataField, FieldPreference>
statuses: ProviderStatus[]
overriddenFields?: Set<MetadataField>
savingField?: string | null
libraryId?: number
}>()
const emit = defineEmits<{
change: [field: MetadataField, pref: FieldPreference]
revert: [field: MetadataField]
}>()
const open = ref(true)
</script>
<template>
<div>
<button
class="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider hover:bg-muted/40 transition-colors"
@click="open = !open"
>
<component :is="open ? ChevronDown : ChevronRight" :size="12" />
{{ label }}
</button>
<div v-if="open" class="divide-y divide-border/60">
<FieldRow
v-for="field in fields"
:key="field"
:field="field"
:preference="preferences[field]"
:statuses="statuses"
:inherited="overriddenFields !== undefined ? !overriddenFields.has(field) : undefined"
:saving="savingField === `${libraryId}:${field}`"
@change="(f, p) => emit('change', f, p)"
@revert="(f) => emit('revert', f)"
/>
</div>
</div>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { FieldPreference, MetadataFetchPreferences, MetadataField, ProviderStatus } from '@projectx/types'
import FieldGroupSection from './FieldGroupSection.vue'
defineProps<{
preferences: MetadataFetchPreferences
statuses: ProviderStatus[]
overriddenFields?: Set<MetadataField>
savingField?: string | null
libraryId?: number
}>()
const emit = defineEmits<{
change: [field: MetadataField, pref: FieldPreference]
revert: [field: MetadataField]
}>()
const GROUPS: { label: string; fields: MetadataField[] }[] = [
{ label: 'Core', fields: ['title', 'subtitle', 'description', 'cover'] },
{ label: 'Contributors', fields: ['authors'] },
{ label: 'Publication', fields: ['publisher', 'publishedYear', 'language', 'pageCount'] },
{ label: 'Series', fields: ['seriesName', 'seriesIndex'] },
{ label: 'Classification', fields: ['genres'] },
]
</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>
</template>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RotateCcw } from 'lucide-vue-next'
import type { FieldPreference, MetadataField, ProviderStatus } from '@projectx/types'
import MergeStrategyPicker from './MergeStrategyPicker.vue'
import ProviderChipList from './ProviderChipList.vue'
const props = defineProps<{
field: MetadataField
preference: FieldPreference
statuses: ProviderStatus[]
inherited?: boolean
saving?: boolean
}>()
const emit = defineEmits<{
change: [field: MetadataField, pref: FieldPreference]
revert: [field: MetadataField]
}>()
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 label = computed(() => FIELD_LABELS[props.field] ?? props.field)
function update(patch: Partial<FieldPreference>) {
emit('change', props.field, { ...props.preference, ...patch })
}
</script>
<template>
<div class="flex flex-col sm:flex-row sm:items-center gap-2 px-4 py-3 transition-colors">
<!-- Enable toggle + label -->
<div class="flex items-center gap-2 sm:w-36 shrink-0">
<input
type="checkbox"
:checked="preference.enabled"
:disabled="saving"
class="h-3.5 w-3.5 rounded border-input accent-primary cursor-pointer"
@change="update({ enabled: ($event.target as HTMLInputElement).checked })"
/>
<span class="text-sm text-foreground font-medium truncate">{{ label }}</span>
</div>
<!-- Provider chip list -->
<div class="flex-1 min-w-0">
<ProviderChipList
:providers="preference.providers"
:statuses="statuses"
:disabled="!preference.enabled || saving"
@update:providers="update({ providers: $event })"
/>
</div>
<!-- Merge strategy + inherited badge + revert -->
<div class="flex items-center gap-2 shrink-0">
<MergeStrategyPicker
:model-value="preference.mergeStrategy"
:disabled="!preference.enabled || saving"
@update:model-value="update({ mergeStrategy: $event })"
/>
<span
v-if="inherited !== undefined"
class="text-[10px] font-medium px-1.5 py-0.5 rounded shrink-0"
:class="inherited ? 'bg-muted text-muted-foreground' : 'bg-primary/10 text-primary'"
>
{{ inherited ? 'inherited' : 'custom' }}
</span>
<button
v-if="inherited === false"
: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"
title="Revert to global default"
@click="$emit('revert', field)"
>
<RotateCcw :size="11" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Loader2, Save } from 'lucide-vue-next'
import type { FieldPreference, MetadataFetchPreferences, MetadataField, ProviderStatus } from '@projectx/types'
import FieldPreferenceTable from './FieldPreferenceTable.vue'
const props = defineProps<{
preferences: MetadataFetchPreferences | null
statuses: ProviderStatus[]
saving: boolean
}>()
const emit = defineEmits<{ save: [prefs: MetadataFetchPreferences] }>()
const draft = ref<MetadataFetchPreferences | null>(null)
watch(
() => props.preferences,
(p) => {
if (p) draft.value = JSON.parse(JSON.stringify(p))
},
{ immediate: true },
)
function onFieldChange(field: MetadataField, pref: FieldPreference) {
if (!draft.value) return
draft.value = { fields: { ...draft.value.fields, [field]: pref } }
}
function save() {
if (!draft.value) return
emit('save', draft.value)
}
</script>
<template>
<div class="border border-border rounded-lg bg-card overflow-hidden">
<div class="px-5 py-4 border-b border-border flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-foreground">Global Defaults</p>
<p class="text-xs text-muted-foreground mt-0.5">Default rules applied to every library. Override per-library below.</p>
</div>
<button
class="flex items-center gap-1.5 h-8 px-3 rounded-md border border-border bg-background text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50"
:disabled="saving || !draft"
@click="save"
>
<Loader2 v-if="saving" :size="13" class="animate-spin" />
<Save v-else :size="13" />
Save
</button>
</div>
<div v-if="draft">
<FieldPreferenceTable :preferences="draft" :statuses="statuses" @change="onFieldChange" />
</div>
<div v-else class="px-5 py-6 text-sm text-muted-foreground">Loading...</div>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ChevronDown, ChevronRight, RotateCcw } from 'lucide-vue-next'
import type { FieldPreference, LibraryMetadataPreferences, MetadataField, ProviderStatus } from '@projectx/types'
import FieldPreferenceTable from './FieldPreferenceTable.vue'
const props = defineProps<{
libraryName: string
libraryPrefs: LibraryMetadataPreferences | null
statuses: ProviderStatus[]
savingField: string | null
}>()
const emit = defineEmits<{
fieldChange: [libraryId: number, field: MetadataField, pref: FieldPreference | null]
reset: [libraryId: number]
}>()
const open = ref(false)
const hasOverrides = computed(() => {
if (!props.libraryPrefs?.overrides) return false
return Object.keys(props.libraryPrefs.overrides).length > 0
})
const overriddenFields = computed<Set<MetadataField>>(() => {
if (!props.libraryPrefs?.overrides) return new Set()
return new Set(Object.keys(props.libraryPrefs.overrides) as MetadataField[])
})
function onFieldChange(field: MetadataField, pref: FieldPreference) {
if (!props.libraryPrefs) return
emit('fieldChange', props.libraryPrefs.libraryId, field, pref)
}
function onRevert(field: MetadataField) {
if (!props.libraryPrefs) return
emit('fieldChange', props.libraryPrefs.libraryId, field, null)
}
function onReset() {
if (!props.libraryPrefs) return
emit('reset', props.libraryPrefs.libraryId)
}
</script>
<template>
<div class="border border-border rounded-lg bg-card overflow-hidden">
<div class="flex items-center gap-3 px-5 py-3.5">
<button class="flex items-center gap-2 flex-1 min-w-0 text-left" @click="open = !open">
<component :is="open ? ChevronDown : ChevronRight" :size="14" class="text-muted-foreground shrink-0" />
<span class="text-sm font-medium text-foreground truncate">{{ libraryName }}</span>
<span
class="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded"
:class="hasOverrides ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'"
>
{{ hasOverrides ? `${overriddenFields.size} override${overriddenFields.size === 1 ? '' : 's'}` : 'using global' }}
</span>
</button>
<button
v-if="hasOverrides"
class="shrink-0 flex items-center gap-1 h-6 px-2 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Reset all to global"
@click="onReset"
>
<RotateCcw :size="11" />
Reset all
</button>
</div>
<div v-if="open && libraryPrefs" class="border-t border-border">
<FieldPreferenceTable
:preferences="libraryPrefs.effective"
:statuses="statuses"
:overridden-fields="overriddenFields"
:saving-field="savingField"
:library-id="libraryPrefs.libraryId"
@change="onFieldChange"
@revert="onRevert"
/>
</div>
<div v-else-if="open && !libraryPrefs" class="border-t border-border px-5 py-4 text-sm text-muted-foreground">Loading...</div>
</div>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { MergeStrategy } from '@projectx/types'
defineProps<{ modelValue: MergeStrategy; disabled?: boolean }>()
defineEmits<{ 'update:modelValue': [value: MergeStrategy] }>()
const options: { value: MergeStrategy; label: string; description: string }[] = [
{ value: 'fillMissing', label: 'Fill missing', description: 'Only write if field is currently empty' },
{ value: 'overwriteIfProvided', label: 'Overwrite if provided', description: 'Write if provider returned a value' },
{ value: 'overwrite', label: 'Always overwrite', description: 'Always replace existing value' },
]
</script>
<template>
<select
:value="modelValue"
:disabled="disabled"
class="h-7 rounded-md border border-input bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-40 cursor-pointer"
:title="options.find((o) => o.value === modelValue)?.description"
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value as MergeStrategy)"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</template>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { ref } from 'vue'
import { GripVertical } from 'lucide-vue-next'
import type { MetadataProviderKey, ProviderStatus } from '@projectx/types'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
const props = defineProps<{
providers: MetadataProviderKey[]
statuses: ProviderStatus[]
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:providers': [value: MetadataProviderKey[]] }>()
function statusFor(key: MetadataProviderKey) {
return props.statuses.find((s) => s.key === key)
}
function isUsable(key: MetadataProviderKey) {
const s = statusFor(key)
return s ? s.enabled && s.configured : true
}
const draggingIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
function onDragStart(index: number, e: DragEvent) {
draggingIndex.value = index
e.dataTransfer!.effectAllowed = 'move'
}
function onDragOver(index: number, e: DragEvent) {
e.preventDefault()
e.dataTransfer!.dropEffect = 'move'
if (draggingIndex.value !== null && draggingIndex.value !== index) {
dragOverIndex.value = index
}
}
function onDragLeave(index: number) {
if (dragOverIndex.value === index) dragOverIndex.value = null
}
function onDrop(targetIndex: number) {
if (draggingIndex.value === null || draggingIndex.value === targetIndex) return
const updated = [...props.providers]
const [moved] = updated.splice(draggingIndex.value, 1)
updated.splice(targetIndex, 0, moved)
emit('update:providers', updated)
draggingIndex.value = null
dragOverIndex.value = null
}
function onDragEnd() {
draggingIndex.value = null
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
}
</script>
<template>
<div class="flex flex-wrap gap-1">
<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-[11px] 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' : '',
]"
style="transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease"
@dragstart="onDragStart(index, $event)"
@dragover="onDragOver(index, $event)"
@dragleave="onDragLeave(index)"
@drop="onDrop(index)"
@dragend="onDragEnd"
>
<GripVertical v-if="!disabled" :size="10" class="opacity-40 shrink-0" />
<span class="opacity-40 tabular-nums leading-none">{{ index + 1 }}</span>
{{ PROVIDER_SHORT_LABELS[key] ?? key }}
</div>
</div>
</template>
@@ -0,0 +1,153 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Loader2, Save } from 'lucide-vue-next'
import type { ProviderConfigurations, ProviderStatus } from '@projectx/types'
const props = defineProps<{
config: ProviderConfigurations | null
statuses: ProviderStatus[]
saving: boolean
}>()
const emit = defineEmits<{ save: [patch: Partial<ProviderConfigurations>] }>()
const draft = ref<ProviderConfigurations | null>(null)
watch(
() => props.config,
(c) => {
if (c) draft.value = JSON.parse(JSON.stringify(c))
},
{ immediate: true },
)
const AMAZON_DOMAINS = [
'amazon.com',
'amazon.co.uk',
'amazon.de',
'amazon.fr',
'amazon.it',
'amazon.es',
'amazon.ca',
'amazon.com.au',
'amazon.co.jp',
'amazon.in',
'amazon.com.br',
'amazon.com.mx',
'amazon.nl',
'amazon.se',
'amazon.pl',
'amazon.sg',
'amazon.ae',
'amazon.sa',
'amazon.tr',
]
type FieldDef =
| { key: string; label: string; type: 'text' | 'password' | 'select'; hint?: string; options?: string[] }
const rows: { key: keyof ProviderConfigurations; label: string; fields: FieldDef[] }[] = [
{
key: 'google',
label: 'Google Books',
fields: [
{
key: 'apiKey',
label: 'API key',
type: 'password',
hint: 'Recommended for higher rate limits. Works without one.',
},
],
},
{
key: 'amazon',
label: 'Amazon',
fields: [
{ key: 'domain', label: 'Region', type: 'select', options: AMAZON_DOMAINS },
{
key: 'cookie',
label: 'Cookie',
type: 'password',
hint: 'Optional but highly recommended - provides richer metadata and avoids rate limiting.',
},
],
},
{ key: 'goodreads', label: 'Goodreads', fields: [] },
{
key: 'hardcover',
label: 'Hardcover',
fields: [{ key: 'apiKey', label: 'API key', type: 'password' }],
},
{ key: 'openLibrary', label: 'Open Library', fields: [] },
]
function statusFor(key: string) {
return props.statuses.find((s) => s.key === key)
}
function save() {
if (!draft.value) return
emit('save', draft.value)
}
</script>
<template>
<div class="border border-border rounded-lg bg-card overflow-hidden">
<div class="px-5 py-4 border-b border-border flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-foreground">Metadata Providers</p>
<p class="text-xs text-muted-foreground mt-0.5">Enable providers and configure API keys or cookies where needed.</p>
</div>
<button
class="flex items-center gap-1.5 h-8 px-3 rounded-md border border-border bg-background text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50"
:disabled="saving || !draft"
@click="save"
>
<Loader2 v-if="saving" :size="13" class="animate-spin" />
<Save v-else :size="13" />
Save
</button>
</div>
<div v-if="draft" class="divide-y divide-border/60">
<div v-for="row in rows" :key="row.key" class="px-5 py-3.5 flex flex-col sm:flex-row sm:items-start gap-3">
<div class="flex items-start gap-3 sm:w-44 shrink-0 pt-0.5">
<input v-model="draft[row.key].enabled" type="checkbox" class="h-3.5 w-3.5 rounded border-input accent-primary cursor-pointer mt-0.5" />
<div>
<p class="text-sm font-medium text-foreground">{{ row.label }}</p>
<template v-if="statusFor(row.key)">
<p v-if="!statusFor(row.key)?.configured" class="text-[10px] text-amber-600">API key required</p>
<p v-else-if="statusFor(row.key)?.hint" class="text-[10px] text-amber-500">{{ statusFor(row.key)?.hint }}</p>
<p v-else class="text-[10px] text-emerald-600">Configured</p>
</template>
</div>
</div>
<div v-if="row.fields.length" class="flex flex-col gap-2 flex-1">
<div v-for="field in row.fields" :key="field.key">
<select
v-if="field.type === 'select'"
v-model="(draft[row.key] as Record<string, string>)[field.key]"
:disabled="!draft[row.key].enabled"
class="w-full h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50 cursor-pointer"
>
<option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
<template v-else>
<input
v-model="(draft[row.key] as Record<string, string>)[field.key]"
:type="field.type"
:placeholder="field.label"
:disabled="!draft[row.key].enabled"
class="w-full h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
/>
<p v-if="field.hint" class="text-[10px] text-muted-foreground mt-0.5">{{ field.hint }}</p>
</template>
</div>
</div>
</div>
</div>
<div v-else class="px-5 py-6 text-sm text-muted-foreground">Loading...</div>
</div>
</template>
@@ -0,0 +1,92 @@
import { ref } from 'vue'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import type { FieldPreference, LibraryMetadataPreferences, MetadataFetchPreferences, MetadataField } from '@projectx/types'
export function useMetadataPreferences() {
const globalPrefs = ref<MetadataFetchPreferences | null>(null)
const libraryPrefs = ref<Map<number, LibraryMetadataPreferences>>(new Map())
const loadingGlobal = ref(false)
const savingGlobal = ref(false)
const savingField = ref<string | null>(null)
async function fetchGlobal() {
loadingGlobal.value = true
try {
const res = await api('/api/metadata-preferences/global')
if (res.ok) globalPrefs.value = await res.json()
} finally {
loadingGlobal.value = false
}
}
async function saveGlobal(prefs: MetadataFetchPreferences) {
savingGlobal.value = true
try {
const res = await api('/api/metadata-preferences/global', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(prefs),
})
if (res.ok) {
await fetchGlobal()
toast.success('Global preferences saved')
} else {
toast.error('Failed to save preferences')
}
} finally {
savingGlobal.value = false
}
}
async function fetchLibrary(libraryId: number) {
const res = await api(`/api/metadata-preferences/libraries/${libraryId}`)
if (res.ok) {
const data: LibraryMetadataPreferences = await res.json()
libraryPrefs.value = new Map(libraryPrefs.value).set(libraryId, data)
}
}
async function saveFieldOverride(libraryId: number, field: MetadataField, pref: FieldPreference | null) {
const key = `${libraryId}:${field}`
savingField.value = key
try {
const url = `/api/metadata-preferences/libraries/${libraryId}/fields/${field}`
const res = await api(url, {
method: pref === null ? 'DELETE' : 'PUT',
headers: pref !== null ? { 'Content-Type': 'application/json' } : undefined,
body: pref !== null ? JSON.stringify(pref) : undefined,
})
if (res.ok || res.status === 204) {
await fetchLibrary(libraryId)
} else {
toast.error('Failed to save field override')
}
} finally {
savingField.value = null
}
}
async function resetLibrary(libraryId: number) {
const res = await api(`/api/metadata-preferences/libraries/${libraryId}`, { method: 'DELETE' })
if (res.ok || res.status === 204) {
await fetchLibrary(libraryId)
toast.success('Library reset to global defaults')
} else {
toast.error('Failed to reset library')
}
}
return {
globalPrefs,
libraryPrefs,
loadingGlobal,
savingGlobal,
savingField,
fetchGlobal,
saveGlobal,
fetchLibrary,
saveFieldOverride,
resetLibrary,
}
}
@@ -0,0 +1,45 @@
import { ref } from 'vue'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import type { ProviderConfigurations, ProviderStatus } from '@projectx/types'
export function useProviderConfig() {
const config = ref<ProviderConfigurations | null>(null)
const statuses = ref<ProviderStatus[]>([])
const loading = ref(false)
const saving = ref(false)
async function fetchConfig() {
loading.value = true
try {
const res = await api('/api/metadata-preferences/providers')
if (!res.ok) return
const data: { config: ProviderConfigurations; statuses: ProviderStatus[] } = await res.json()
config.value = data.config
statuses.value = data.statuses
} finally {
loading.value = false
}
}
async function saveConfig(patch: Partial<ProviderConfigurations>) {
saving.value = true
try {
const res = await api('/api/metadata-preferences/providers', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
if (res.ok) {
await fetchConfig()
toast.success('Provider settings saved')
} else {
toast.error('Failed to save provider settings')
}
} finally {
saving.value = false
}
}
return { config, statuses, loading, saving, fetchConfig, saveConfig }
}
+48
View File
@@ -0,0 +1,48 @@
import type { MetadataProviderKey } from '@projectx/types'
const PROVIDER_HEX: Record<string, string> = {
google: '#34A853',
goodreads: '#06B6D4',
amazon: '#FF9900',
hardcover: '#4F47E5',
openLibrary: '#E83B2A',
}
const DEFAULT_COLOR = 'oklch(0.5 0.01 0)'
export const PROVIDER_SHORT_LABELS: Record<string, string> = {
google: 'Google',
amazon: 'Amazon',
goodreads: 'Goodreads',
hardcover: 'Hardcover',
openLibrary: 'Open Lib',
}
export function getProviderColor(provider: string): string {
return PROVIDER_HEX[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)
}
export function providerChipStyle(provider: MetadataProviderKey | string, disabled = false): Record<string, string> {
const color = getProviderColor(provider)
const style = makeProviderPillStyle(color, disabled ? 6 : 14, disabled ? 15 : 35)
if (disabled) style.opacity = '0.45'
return style
}