mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): add preferences, provider priority, and cover updates
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
+59
@@ -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>
|
||||
+86
@@ -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>
|
||||
+92
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user