feat(metadata): batch library overrides and improve comic refresh

This commit is contained in:
neon
2026-03-24 20:09:15 -06:00
parent f847cc1d22
commit d2eba30728
43 changed files with 1022 additions and 612 deletions
+5 -1
View File
@@ -53,6 +53,10 @@ function navigateToAccount() {
router.push({ name: 'settings-account' })
}
function navigateToSettings() {
router.push({ name: 'settings-libraries' })
}
const uploadOpen = ref(false)
const searchFocused = ref(false)
@@ -512,7 +516,7 @@ function formatBadgeClass(fmt: string): string {
variant="ghost"
size="icon"
class="h-8 w-8 rounded-xl border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors"
@click="router.push({ name: 'settings-account' })"
@click="navigateToSettings"
>
<Settings :size="15" />
</Button>
@@ -27,6 +27,7 @@ const { search: searchAuthors } = useAuthorSearch()
const { search: searchNarrators } = useNarratorSearch()
const { search: searchGenres } = useGenreSearch()
const { search: searchTags } = useTagSearch()
const searchComicMetadata = async (q: string): Promise<string[]> => (q.trim() ? [] : [])
const coverPanel = ref<InstanceType<typeof CoverEditorPanel> | null>(null)
const searchOpen = ref(false)
@@ -130,6 +131,20 @@ async function autoFill() {
if (preview.openLibraryId != null) form.openLibraryId = preview.openLibraryId
if (preview.itunesId != null) form.itunesId = preview.itunesId
if (preview.audibleId != null) form.audibleId = preview.audibleId
if (preview.comicvineId != null) form.comicvineId = preview.comicvineId
if (preview.comicMetadata) {
if (preview.comicMetadata.issueNumber !== undefined) form.comicIssueNumber = preview.comicMetadata.issueNumber
if (preview.comicMetadata.volumeName !== undefined) form.comicVolumeName = preview.comicMetadata.volumeName
if (preview.comicMetadata.storyArcs !== undefined) form.comicStoryArcs = preview.comicMetadata.storyArcs
if (preview.comicMetadata.pencillers !== undefined) form.comicPencillers = preview.comicMetadata.pencillers
if (preview.comicMetadata.inkers !== undefined) form.comicInkers = preview.comicMetadata.inkers
if (preview.comicMetadata.colorists !== undefined) form.comicColorists = preview.comicMetadata.colorists
if (preview.comicMetadata.letterers !== undefined) form.comicLetterers = preview.comicMetadata.letterers
if (preview.comicMetadata.coverArtists !== undefined) form.comicCoverArtists = preview.comicMetadata.coverArtists
if (preview.comicMetadata.characters !== undefined) form.comicCharacters = preview.comicMetadata.characters
if (preview.comicMetadata.teams !== undefined) form.comicTeams = preview.comicMetadata.teams
if (preview.comicMetadata.locations !== undefined) form.comicLocations = preview.comicMetadata.locations
}
if (preview.coverUrl) coverPanel.value?.setUrl(preview.coverUrl)
}
</script>
@@ -391,50 +406,50 @@ async function autoFill() {
<!-- Story Arcs -->
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Story Arcs</label>
<ChipInput v-model="form.comicStoryArcs" />
<ChipInput v-model="form.comicStoryArcs" :search-fn="searchComicMetadata" />
</div>
<!-- Pencillers | Inkers -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pencillers</label>
<ChipInput v-model="form.comicPencillers" />
<ChipInput v-model="form.comicPencillers" :search-fn="searchComicMetadata" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Inkers</label>
<ChipInput v-model="form.comicInkers" />
<ChipInput v-model="form.comicInkers" :search-fn="searchComicMetadata" />
</div>
</div>
<!-- Colorists | Letterers -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Colorists</label>
<ChipInput v-model="form.comicColorists" />
<ChipInput v-model="form.comicColorists" :search-fn="searchComicMetadata" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Letterers</label>
<ChipInput v-model="form.comicLetterers" />
<ChipInput v-model="form.comicLetterers" :search-fn="searchComicMetadata" />
</div>
</div>
<!-- Cover Artists -->
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Cover Artists</label>
<ChipInput v-model="form.comicCoverArtists" />
<ChipInput v-model="form.comicCoverArtists" :search-fn="searchComicMetadata" />
</div>
<!-- Characters | Teams -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Characters</label>
<ChipInput v-model="form.comicCharacters" />
<ChipInput v-model="form.comicCharacters" :search-fn="searchComicMetadata" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Teams</label>
<ChipInput v-model="form.comicTeams" />
<ChipInput v-model="form.comicTeams" :search-fn="searchComicMetadata" />
</div>
</div>
<!-- Locations -->
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Locations</label>
<ChipInput v-model="form.comicLocations" />
<ChipInput v-model="form.comicLocations" :search-fn="searchComicMetadata" />
</div>
</div>
</div>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { X, Sparkles, ChevronRight } from 'lucide-vue-next'
import { isAudioFormat } from '@projectx/types'
import type { BookDetail, MetadataCandidate, MetadataSource } from '@projectx/types'
import { useMetadataSearch } from '../../../composables/useMetadataSearch'
import { useCoverVersions } from '../../../composables/useCoverVersions'
@@ -62,7 +63,7 @@ onMounted(() => {
})
function handleSearch(params: { title: string; author: string; isbn: string }) {
const isAudiobook = props.book.durationSeconds != null || !!props.book.providerIds.audible
const isAudiobook = props.book.files.some((f) => f.format != null && isAudioFormat(f.format))
selectedCandidate.value = null
view.value = 'search'
search({ ...params, bookId: props.book.id, isAudiobook })
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { BookDetail } from '@projectx/types'
import type { BookDetail, ComicMetadataFields } from '@projectx/types'
import { useCoverVersions } from './useCoverVersions'
import { toast } from 'vue-sonner'
@@ -24,6 +24,8 @@ export interface MetadataRefreshPreview {
openLibraryId?: string | null
itunesId?: string | null
audibleId?: string | null
comicvineId?: string | null
comicMetadata?: ComicMetadataFields
}
export function useRefreshMetadata() {
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import type { FieldPreference, MetadataField } from '@projectx/types'
import type { FieldPreferenceOverrides } from '@projectx/types'
import { Info } from 'lucide-vue-next'
import { useLibraries } from '@/features/library/composables/useLibraries'
import GlobalPreferencePanel from './components/GlobalPreferencePanel.vue'
@@ -9,8 +9,19 @@ import { useProviderConfig } from './composables/useProviderConfig'
import { useMetadataPreferences } from './composables/useMetadataPreferences'
const { statuses, fetchConfig } = useProviderConfig()
const { globalPrefs, libraryPrefs, savingGlobal, savingField, fetchGlobal, saveGlobal, fetchLibrary, saveFieldOverride, resetLibrary } =
useMetadataPreferences()
const {
globalPrefs,
libraryPrefs,
savingGlobal,
savingLibrary,
fetchGlobal,
saveGlobal,
resetGlobal,
clearAllProviders,
fetchLibrary,
saveLibraryDraft,
resetLibrary,
} = useMetadataPreferences()
const { libraries, fetchLibraries } = useLibraries()
onMounted(() => {
@@ -19,14 +30,18 @@ onMounted(() => {
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)))
})
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)))
},
{ immediate: true },
)
async function onFieldChange(libraryId: number, field: MetadataField, pref: FieldPreference | null) {
await saveFieldOverride(libraryId, field, pref)
async function onSaveLibrary(libraryId: number, overrides: FieldPreferenceOverrides) {
await saveLibraryDraft(libraryId, overrides)
}
async function onResetLibrary(libraryId: number) {
@@ -56,7 +71,14 @@ async function onResetLibrary(libraryId: number) {
</div>
</div>
<GlobalPreferencePanel :preferences="globalPrefs" :statuses="statuses" :saving="savingGlobal" @save="saveGlobal" />
<GlobalPreferencePanel
:preferences="globalPrefs"
:statuses="statuses"
:saving="savingGlobal"
@save="saveGlobal"
@clear-all="clearAllProviders"
@reset-to-default="resetGlobal"
/>
<div v-if="libraries.length" class="space-y-4">
<div class="px-1">
@@ -68,10 +90,12 @@ async function onResetLibrary(libraryId: number) {
v-for="lib in libraries"
:key="lib.id"
:library-name="lib.name"
:library-primary-format="lib.formatPriority[0] ?? null"
:library-prefs="libraryPrefs.get(lib.id) ?? null"
:global-prefs="globalPrefs"
:statuses="statuses"
:saving-field="savingField"
@field-change="onFieldChange"
:saving="savingLibrary === lib.id"
@save="onSaveLibrary"
@reset="onResetLibrary"
/>
</div>
@@ -10,8 +10,7 @@ defineProps<{
preferences: Record<MetadataField, FieldPreference>
statuses: ProviderStatus[]
overriddenFields?: Set<MetadataField>
savingField?: string | null
libraryId?: number
saving?: boolean
}>()
const emit = defineEmits<{
@@ -40,7 +39,7 @@ const open = ref(true)
:preference="preferences[field]"
:statuses="statuses"
:inherited="overriddenFields !== undefined ? !overriddenFields.has(field) : undefined"
:saving="savingField === `${libraryId}:${field}`"
:saving="saving"
@change="(f, p) => emit('change', f, p)"
@revert="(f) => emit('revert', f)"
/>
@@ -8,8 +8,7 @@ defineProps<{
preferences: MetadataFetchPreferences
statuses: ProviderStatus[]
overriddenFields?: Set<MetadataField>
savingField?: string | null
libraryId?: number
saving?: boolean
}>()
const emit = defineEmits<{
@@ -65,8 +64,7 @@ const GROUPS: { label: string; fields: MetadataField[] }[] = [
:preferences="preferences.fields"
:statuses="statuses"
:overridden-fields="overriddenFields"
:saving-field="savingField"
:library-id="libraryId"
:saving="saving"
@change="(f, p) => emit('change', f, p)"
@revert="(f) => emit('revert', f)"
/>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { AlertTriangle, RotateCcw, Settings2 } from 'lucide-vue-next'
import type { FieldPreference, MetadataField, MetadataProviderKey, ProviderStatus } from '@projectx/types'
import type { FieldPreference, MetadataField, ProviderStatus } from '@projectx/types'
import MergeStrategyPicker from './MergeStrategyPicker.vue'
import ProviderChipList from './ProviderChipList.vue'
import FieldConfigSheet from './FieldConfigSheet.vue'
@@ -9,8 +9,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { Badge } from '@/components/ui/badge'
const RESERVOIR_TYPE = 'application/x-provider-key'
const props = defineProps<{
field: MetadataField
preference: FieldPreference
@@ -45,7 +43,6 @@ 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 })
@@ -54,46 +51,10 @@ function update(patch: Partial<FieldPreference>) {
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 md:flex-row md:items-center gap-4 px-6 py-3.5 transition-colors relative hover:bg-muted/15"
:class="isDragOver ? 'bg-primary/5 outline outline-primary/25 -outline-offset-1' : ''"
@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-bold bg-card border-2 border-primary/30 px-4 py-1.5 rounded-full shadow-lg animate-in zoom-in-95 duration-200"
>
Drop to add to {{ label }}
</span>
</div>
<div class="flex flex-col md:flex-row md:items-center gap-4 px-6 py-3.5 transition-colors relative hover:bg-muted/15">
<!-- Enable toggle + label -->
<div class="flex items-center gap-3 md:w-44 shrink-0">
<div
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Loader2, Save, Settings } from 'lucide-vue-next'
import { Loader2, RotateCcw, Save, Settings, Trash2 } from 'lucide-vue-next'
import type { FieldPreference, MetadataFetchPreferences, MetadataField, ProviderStatus } from '@projectx/types'
import FieldPreferenceTable from './FieldPreferenceTable.vue'
@@ -10,7 +10,11 @@ const props = defineProps<{
saving: boolean
}>()
const emit = defineEmits<{ save: [prefs: MetadataFetchPreferences] }>()
const emit = defineEmits<{
save: [prefs: MetadataFetchPreferences]
clearAll: [prefs: MetadataFetchPreferences]
resetToDefault: []
}>()
const draft = ref<MetadataFetchPreferences | null>(null)
@@ -19,10 +23,9 @@ function withDefaultOptions(prefs: MetadataFetchPreferences): MetadataFetchPrefe
...prefs,
options: {
genres: {
mode: prefs.options?.genres.mode ?? 'firstProvider',
providerScope: prefs.options?.genres.providerScope ?? 'selectedProviders',
mode: prefs.options?.genres.mode ?? 'merge',
},
saveProviderIds: prefs.options?.saveProviderIds ?? false,
saveProviderIds: prefs.options?.saveProviderIds ?? true,
},
}
}
@@ -49,6 +52,22 @@ function setGenreMerge(enabled: boolean) {
if (!draft.value?.options) return
draft.value.options.genres.mode = enabled ? 'merge' : 'firstProvider'
}
function toggleSaveProviderIds() {
if (!draft.value?.options) return
draft.value.options.saveProviderIds = !draft.value.options.saveProviderIds
}
function handleClearAll() {
if (!draft.value) return
if (!confirm('Remove all active providers from every field? This will be saved immediately and cannot be undone.')) return
emit('clearAll', draft.value)
}
function handleResetToDefault() {
if (!confirm('Reset all global field rules to system defaults? This will overwrite your current configuration.')) return
emit('resetToDefault')
}
</script>
<template>
@@ -58,11 +77,29 @@ function setGenreMerge(enabled: boolean) {
<span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">Global Defaults</span>
<p class="settings-hint">Default rules applied to every library. Override per-library below.</p>
</div>
<button class="settings-btn-primary h-8 px-3" :disabled="saving || !draft" @click="save">
<Loader2 v-if="saving" :size="14" class="animate-spin" />
<Save v-else :size="14" />
<span>Save Defaults</span>
</button>
<div class="flex items-center gap-2 flex-wrap">
<button
class="settings-btn h-8 px-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground border border-border rounded-md hover:text-destructive hover:border-destructive/50 hover:bg-destructive/5 transition-colors disabled:opacity-40"
:disabled="saving || !draft"
@click="handleClearAll"
>
<Trash2 :size="13" />
<span>Clear All Providers</span>
</button>
<button
class="settings-btn h-8 px-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground border border-border rounded-md hover:text-foreground hover:border-border/80 hover:bg-muted/50 transition-colors disabled:opacity-40"
:disabled="saving"
@click="handleResetToDefault"
>
<RotateCcw :size="13" />
<span>Reset to Default</span>
</button>
<button class="settings-btn-primary h-8 px-3" :disabled="saving || !draft" @click="save">
<Loader2 v-if="saving" :size="14" class="animate-spin" />
<Save v-else :size="14" />
<span>Save Defaults</span>
</button>
</div>
</div>
<div v-if="draft">
@@ -90,33 +127,12 @@ function setGenreMerge(enabled: boolean) {
/>
</div>
<div class="space-y-1">
<span class="text-sm font-medium text-foreground">Merge genres across providers</span>
<p class="text-xs text-muted-foreground">Instead of taking genres from a single provider, combine them from multiple sources.</p>
<span class="text-sm font-medium text-foreground">Combine genres from all selected providers</span>
<p class="text-xs text-muted-foreground">
Collect and deduplicate genres from every provider assigned to the Genres field, rather than stopping at the first result.
</p>
</div>
</label>
<fieldset class="pl-12 space-y-3" :class="draft.options?.genres.mode !== 'merge' ? 'opacity-40 pointer-events-none' : ''">
<label class="flex items-center gap-2.5 cursor-pointer group">
<input
type="radio"
name="genre-provider-scope"
class="h-4 w-4 border-input accent-primary cursor-pointer"
:checked="draft.options?.genres.providerScope === 'selectedProviders'"
@change="draft.options && (draft.options.genres.providerScope = 'selectedProviders')"
/>
<span class="text-sm text-foreground group-hover:text-primary transition-colors">Use only providers selected for Genres field</span>
</label>
<label class="flex items-center gap-2.5 cursor-pointer group">
<input
type="radio"
name="genre-provider-scope"
class="h-4 w-4 border-input accent-primary cursor-pointer"
:checked="draft.options?.genres.providerScope === 'allConfiguredProviders'"
@change="draft.options && (draft.options.genres.providerScope = 'allConfiguredProviders')"
/>
<span class="text-sm text-foreground group-hover:text-primary transition-colors">Use all enabled and configured providers</span>
</label>
</fieldset>
</div>
<!-- IDs Behavior -->
@@ -125,7 +141,7 @@ function setGenreMerge(enabled: boolean) {
<div
class="relative flex h-5 w-9 shrink-0 items-center rounded-full transition-colors mt-0.5"
:class="draft.options?.saveProviderIds ? 'bg-primary' : 'bg-muted border border-border'"
@click.prevent="draft.options && (draft.options.saveProviderIds = !draft.options.saveProviderIds)"
@click.prevent="toggleSaveProviderIds"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
@@ -133,9 +149,10 @@ function setGenreMerge(enabled: boolean) {
/>
</div>
<div class="space-y-1">
<span class="text-sm font-medium text-foreground">Save provider IDs</span>
<span class="text-sm font-medium text-foreground">Store provider IDs on books</span>
<p class="text-xs text-muted-foreground">
Persist IDs (ISBN, ASIN, Goodreads ID, etc.) back to the book records during auto-refresh.
When fetching metadata, save the returned provider IDs (ISBN, ASIN, Goodreads ID, etc.) on the book record for more accurate future
lookups.
</p>
</div>
</label>
@@ -1,49 +1,119 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ChevronDown, ChevronRight, RotateCcw, Library } from 'lucide-vue-next'
import type { FieldPreference, LibraryMetadataPreferences, MetadataField, ProviderStatus } from '@projectx/types'
import { computed, ref, watch, type CSSProperties } from 'vue'
import { ChevronDown, ChevronRight, Loader2, RotateCcw, Library, Trash2, Save } from 'lucide-vue-next'
import type {
FieldPreference,
FieldPreferenceOverrides,
LibraryMetadataPreferences,
MetadataFetchPreferences,
MetadataField,
ProviderStatus,
} from '@projectx/types'
import { ALL_METADATA_FIELDS } from '@projectx/types'
import FieldPreferenceTable from './FieldPreferenceTable.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Badge } from '@/components/ui/badge'
import { getFormatColor } from '@/features/book/lib/format-colors'
const props = defineProps<{
libraryName: string
libraryPrimaryFormat: string | null
libraryPrefs: LibraryMetadataPreferences | null
globalPrefs: MetadataFetchPreferences | null
statuses: ProviderStatus[]
savingField: string | null
saving?: boolean
}>()
const emit = defineEmits<{
fieldChange: [libraryId: number, field: MetadataField, pref: FieldPreference | null]
save: [libraryId: number, overrides: FieldPreferenceOverrides]
reset: [libraryId: number]
}>()
const open = ref(false)
const draft = ref<MetadataFetchPreferences | null>(null)
const pendingChanges = ref<Map<MetadataField, FieldPreference | null>>(new Map())
const hasOverrides = computed(() => {
watch(
() => props.libraryPrefs,
(lp) => {
if (!lp) return
draft.value = JSON.parse(JSON.stringify(lp.effective))
pendingChanges.value = new Map()
},
{ immediate: true },
)
const isDirty = computed(() => pendingChanges.value.size > 0)
const hasPersistedOverrides = 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[])
const base = new Set<MetadataField>(Object.keys(props.libraryPrefs?.overrides ?? {}) as MetadataField[])
for (const [field, pref] of pendingChanges.value) {
if (pref === null) base.delete(field)
else base.add(field)
}
return base
})
const primaryFormat = computed(() => {
if (!props.libraryPrimaryFormat) return 'ANY'
return props.libraryPrimaryFormat.toUpperCase()
})
const primaryFormatStyle = computed<CSSProperties>(() => ({
color: getFormatColor(props.libraryPrimaryFormat),
}))
function toggleOpen() {
open.value = !open.value
}
function onFieldChange(field: MetadataField, pref: FieldPreference) {
if (!props.libraryPrefs) return
emit('fieldChange', props.libraryPrefs.libraryId, field, pref)
if (!draft.value || props.saving) return
draft.value = { ...draft.value, fields: { ...draft.value.fields, [field]: pref } }
pendingChanges.value = new Map(pendingChanges.value).set(field, pref)
}
function onRevert(field: MetadataField) {
if (!props.libraryPrefs) return
emit('fieldChange', props.libraryPrefs.libraryId, field, null)
if (!draft.value || !props.globalPrefs || props.saving) return
const globalField = props.globalPrefs.fields[field]
draft.value = { ...draft.value, fields: { ...draft.value.fields, [field]: { ...globalField } } }
pendingChanges.value = new Map(pendingChanges.value).set(field, null)
}
function onClearAll() {
if (!draft.value) return
if (!confirm(`Remove all active providers from every field in "${props.libraryName}"?`)) return
const updatedFields = { ...draft.value.fields }
const newChanges = new Map(pendingChanges.value)
for (const field of ALL_METADATA_FIELDS) {
const updated = { ...updatedFields[field], providers: [] }
updatedFields[field] = updated
newChanges.set(field, updated)
}
draft.value = { ...draft.value, fields: updatedFields as MetadataFetchPreferences['fields'] }
pendingChanges.value = newChanges
}
function onReset() {
if (!props.libraryPrefs) return
if (!confirm(`Reset "${props.libraryName}" to global defaults? All library overrides will be removed.`)) return
emit('reset', props.libraryPrefs.libraryId)
}
function onSave() {
if (!props.libraryPrefs || !isDirty.value) return
const newOverrides: FieldPreferenceOverrides = { ...props.libraryPrefs.overrides }
for (const [field, pref] of pendingChanges.value) {
if (pref === null) delete newOverrides[field]
else newOverrides[field] = pref
}
emit('save', props.libraryPrefs.libraryId, newOverrides)
}
</script>
<template>
@@ -51,7 +121,8 @@ function onReset() {
class="border border-border rounded-xl bg-card overflow-hidden shadow-sm transition-all"
:class="open ? 'ring-1 ring-primary/20' : 'hover:border-primary/30'"
>
<div class="flex items-center gap-3 px-5 py-4 cursor-pointer select-none" @click="open = !open">
<!-- Accordion toggle row -->
<div class="flex items-center gap-3 px-5 py-4 cursor-pointer select-none" @click="toggleOpen">
<div class="flex items-center gap-4 flex-1 min-w-0">
<div
class="flex items-center justify-center w-8 h-8 rounded-lg bg-muted shrink-0 text-muted-foreground transition-colors"
@@ -62,50 +133,84 @@ function onReset() {
<div class="min-w-0 space-y-0.5">
<div class="flex items-center gap-3">
<span class="text-sm font-semibold text-foreground truncate">{{ libraryName }}</span>
<Badge v-if="hasOverrides" variant="secondary" class="h-4.5 px-1.5 text-[10px] font-bold uppercase tracking-tight">
{{ overriddenFields.size }} {{ overriddenFields.size === 1 ? 'Override' : 'Overrides' }}
<Badge variant="outline" class="h-4.5 px-1.5 text-[10px] font-bold uppercase tracking-tight">
<span class="text-muted-foreground">Primary:</span>
<span class="ml-1" :style="primaryFormatStyle">{{ primaryFormat }}</span>
</Badge>
<Badge v-if="overriddenFields.size > 0" variant="secondary" class="h-4.5 px-1.5 text-[10px] font-bold uppercase tracking-tight">
{{ overriddenFields.size }} {{ overriddenFields.size === 1 ? 'Override' : 'Overrides' }}
<span v-if="isDirty" class="ml-1 opacity-60">(unsaved)</span>
</Badge>
<Badge v-else variant="outline" class="h-4.5 px-1.5 text-[10px] font-bold uppercase tracking-tight opacity-60">
{{ isDirty ? 'Unsaved changes' : 'Global Defaults' }}
</Badge>
<Badge v-else variant="outline" class="h-4.5 px-1.5 text-[10px] font-bold uppercase tracking-tight opacity-60"> Global Defaults </Badge>
</div>
<p class="text-[11px] text-muted-foreground font-mono truncate" v-if="!open">
{{ hasOverrides ? `Overriding: ${Array.from(overriddenFields).join(', ')}` : 'Inheriting all global metadata rules' }}
<p v-if="!open" class="text-[11px] text-muted-foreground font-mono truncate">
{{
hasPersistedOverrides
? `Overriding: ${Array.from(Object.keys(libraryPrefs?.overrides ?? {})).join(', ')}`
: 'Inheriting all global metadata rules'
}}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<Tooltip v-if="hasOverrides">
<TooltipTrigger as-child>
<button
class="shrink-0 flex items-center justify-center h-8 w-8 rounded-md border border-border bg-background text-muted-foreground hover:text-destructive hover:bg-destructive/5 hover:border-destructive/30 transition-all"
@click.stop="onReset"
>
<RotateCcw :size="13" />
</button>
</TooltipTrigger>
<TooltipContent>Reset library to global defaults</TooltipContent>
</Tooltip>
<div class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-muted transition-colors">
<component :is="open ? ChevronDown : ChevronRight" :size="16" class="text-muted-foreground" />
</div>
<div class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-muted transition-colors">
<component :is="open ? ChevronDown : ChevronRight" :size="16" class="text-muted-foreground" />
</div>
</div>
<div v-if="open && libraryPrefs" class="border-t border-border bg-muted/5 animate-in fade-in slide-in-from-top-1 duration-200">
<FieldPreferenceTable
:preferences="libraryPrefs.effective"
:statuses="statuses"
:overridden-fields="overriddenFields"
:saving-field="savingField"
:library-id="libraryPrefs.libraryId"
@change="onFieldChange"
@revert="onRevert"
/>
</div>
<!-- Expanded content -->
<template v-if="open">
<div v-if="draft">
<!-- Action sub-header -->
<div class="px-5 py-4 border-t border-border flex flex-col md:flex-row md:items-center justify-between gap-4 bg-muted/30">
<div>
<span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">{{ libraryName }}</span>
<p class="settings-hint">Fields not overridden inherit global defaults.</p>
</div>
<div class="flex items-center gap-2 flex-wrap">
<button
class="settings-btn h-8 px-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground border border-border rounded-md hover:text-destructive hover:border-destructive/50 hover:bg-destructive/5 transition-colors disabled:opacity-40"
:disabled="saving || !draft"
@click="onClearAll"
>
<Trash2 :size="13" />
<span>Clear All Providers</span>
</button>
<Tooltip>
<TooltipTrigger as-child>
<button
class="settings-btn h-8 px-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground border border-border rounded-md hover:text-foreground hover:border-border/80 hover:bg-muted/50 transition-colors disabled:opacity-40"
:disabled="saving"
@click="onReset"
>
<RotateCcw :size="13" />
<span>Reset to Default</span>
</button>
</TooltipTrigger>
<TooltipContent>Remove all overrides and inherit global defaults</TooltipContent>
</Tooltip>
<button class="settings-btn-primary h-8 px-3" :disabled="saving || !isDirty" @click="onSave">
<Loader2 v-if="saving" :size="14" class="animate-spin" />
<Save v-else :size="14" />
<span>Save</span>
</button>
</div>
</div>
<div v-else-if="open && !libraryPrefs" class="border-t border-border px-6 py-10 flex items-center justify-center">
<Loader2 :size="20" class="animate-spin text-muted-foreground" />
</div>
<FieldPreferenceTable
:preferences="draft"
:statuses="statuses"
:overridden-fields="overriddenFields"
:saving="saving"
@change="onFieldChange"
@revert="onRevert"
/>
</div>
<div v-else class="border-t border-border px-6 py-10 flex items-center justify-center">
<Loader2 :size="20" class="animate-spin text-muted-foreground" />
</div>
</template>
</div>
</template>
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
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'
import { PROVIDER_DND_GROUP, toProviderDragItems } from '../lib/provider-drag'
const props = defineProps<{
providers: MetadataProviderKey[]
@@ -14,6 +14,11 @@ const props = defineProps<{
const emit = defineEmits<{ 'update:providers': [value: MetadataProviderKey[]] }>()
interface ProviderChipItem {
key: MetadataProviderKey
dragId: string
}
function statusFor(key: MetadataProviderKey) {
return props.statuses.find((s) => s.key === key)
}
@@ -23,90 +28,104 @@ function isUsable(key: MetadataProviderKey) {
return s ? s.enabled && s.configured : true
}
const draggingIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
const localProviders = ref<ProviderChipItem[]>([])
const isDragging = ref(false)
function onDragStart(index: number, e: DragEvent) {
draggingIndex.value = index
e.dataTransfer!.effectAllowed = 'move'
e.dataTransfer!.setData(REORDER_TYPE, String(index))
e.stopPropagation()
watch(
() => props.providers,
(providers) => {
if (isDragging.value) return
localProviders.value = toProviderDragItems(providers)
},
{ immediate: true },
)
function sameOrder(a: MetadataProviderKey[], b: MetadataProviderKey[]) {
return a.length === b.length && a.every((value, index) => value === b[index])
}
function onDragOver(index: number, e: DragEvent) {
if (!e.dataTransfer?.types.includes(REORDER_TYPE)) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
if (draggingIndex.value !== null && draggingIndex.value !== index) {
dragOverIndex.value = index
function commitProviders() {
const seen = new Set<MetadataProviderKey>()
const deduplicated = localProviders.value.filter((item) => {
if (seen.has(item.key)) return false
seen.add(item.key)
return true
})
if (deduplicated.length !== localProviders.value.length) {
localProviders.value = deduplicated
}
const normalized = deduplicated.map((item) => item.key)
if (!sameOrder(normalized, props.providers)) {
emit('update:providers', normalized)
}
}
function onDragLeave(index: number) {
if (dragOverIndex.value === index) dragOverIndex.value = null
}
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)
updated.splice(targetIndex, 0, moved)
emit('update:providers', updated)
draggingIndex.value = null
dragOverIndex.value = null
function onDragStart() {
isDragging.value = true
}
function onDragEnd() {
draggingIndex.value = null
dragOverIndex.value = null
isDragging.value = false
commitProviders()
}
function removeProvider(index: number) {
const updated = [...props.providers]
const updated = [...localProviders.value]
updated.splice(index, 1)
emit('update:providers', updated)
localProviders.value = updated
commitProviders()
}
</script>
<template>
<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="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/60 scale-105' : '',
]"
style="transition: transform 0.12s ease"
@dragstart="onDragStart(index, $event)"
@dragover="onDragOver(index, $event)"
@dragleave="onDragLeave(index)"
@drop="onDrop(index, $event)"
@dragend="onDragEnd"
<div class="min-h-[26px]">
<VueDraggable
v-model="localProviders"
item-key="dragId"
tag="div"
class="flex flex-wrap gap-1.5 min-h-[26px] items-center"
:animation="150"
:group="{ name: PROVIDER_DND_GROUP, pull: false, put: true }"
handle=".provider-chip-handle"
ghost-class="opacity-40"
chosen-class="provider-chip-chosen"
drag-class="scale-95"
:disabled="disabled"
@start="onDragStart"
@add="commitProviders"
@end="onDragEnd"
>
<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
<div
v-for="(item, index) in localProviders"
:key="item.dragId"
:title="statusFor(item.key)?.label ?? item.key"
class="flex items-center gap-1 h-6 pl-1.5 pr-1 rounded text-xs font-medium select-none transition-transform"
:style="providerChipStyle(item.key, !isUsable(item.key))"
:class="!disabled ? 'provider-chip-handle cursor-grab active:cursor-grabbing' : 'cursor-default'"
>
<X :size="12" :stroke-width="3" />
</button>
</div>
<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[item.key] ?? item.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>
</VueDraggable>
<span v-if="providers.length === 0 && !disabled" class="text-xs text-muted-foreground/60 italic h-6 flex items-center px-1">
<span v-if="localProviders.length === 0 && !disabled" class="text-xs text-muted-foreground/60 italic h-6 flex items-center px-1">
drag a provider here
</span>
</div>
</template>
<style scoped>
.provider-chip-chosen {
outline: 2px solid color-mix(in oklch, var(--primary) 55%, white);
outline-offset: 1px;
}
</style>
@@ -102,11 +102,8 @@ const rows: { key: keyof ProviderConfigurations; label: string; hint?: string; f
{
key: 'audible',
label: 'Audible',
hint: 'Pulls audiobook metadata from Audible product pages. A session cookie is highly recommended.',
fields: [
{ key: 'domain', label: 'Region', type: 'select', options: AUDIBLE_DOMAINS },
{ key: 'cookie', label: 'Cookie', type: 'password' },
],
hint: 'Pulls audiobook metadata from Audible. No additional setup is required.',
fields: [{ key: 'domain', label: 'Region', type: 'select', options: AUDIBLE_DOMAINS }],
},
{ key: 'audnexus', label: 'AudNexus', hint: 'Community-driven audiobook metadata. No setup required.', fields: [] },
{
@@ -160,6 +157,11 @@ function save() {
if (!draft.value) return
emit('save', draft.value)
}
function onSecretFieldFocus(event: FocusEvent) {
const input = event.target as HTMLInputElement | null
if (input?.readOnly) input.readOnly = false
}
</script>
<template>
@@ -230,10 +232,21 @@ function save() {
<input
v-else
v-model="(draft[row.key] as unknown as Record<string, string>)[field.key]"
:type="field.type"
:type="field.type === 'password' ? 'text' : field.type"
:name="`metadata-${row.key}-${field.key}`"
:placeholder="field.label"
:disabled="!draft[row.key].enabled"
:readonly="field.type === 'password'"
:autocomplete="field.type === 'password' ? 'off' : 'off'"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
:data-lpignore="field.type === 'password' ? 'true' : undefined"
:data-1p-ignore="field.type === 'password' ? 'true' : undefined"
:data-form-type="field.type === 'password' ? 'other' : undefined"
:style="field.type === 'password' ? '--webkit-text-security: disc' : undefined"
class="h-9 w-64 lg:w-80 rounded-md border border-input bg-background px-3 text-xs font-medium placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-40 transition-all"
@focus="field.type === 'password' ? onSecretFieldFocus($event) : undefined"
/>
</div>
</div>
@@ -1,40 +1,67 @@
<script setup lang="ts">
import { computed } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { GripVertical } from 'lucide-vue-next'
import type { ProviderStatus } from '@projectx/types'
import type { MetadataProviderKey, ProviderStatus } from '@projectx/types'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { PROVIDER_DND_GROUP, createProviderDragItem, type ProviderDragItem } from '../lib/provider-drag'
defineProps<{
const props = defineProps<{
statuses: ProviderStatus[]
}>()
function onDragStart(key: string, e: DragEvent) {
e.dataTransfer!.effectAllowed = 'copy'
e.dataTransfer!.setData('application/x-provider-key', key)
interface ReservoirProviderItem {
key: MetadataProviderKey
label: string
usable: boolean
}
const reservoirProviders = computed<ReservoirProviderItem[]>(() =>
props.statuses.map((status) => ({
key: status.key as MetadataProviderKey,
label: status.label,
usable: status.enabled && status.configured,
})),
)
function cloneProvider(item: ReservoirProviderItem): ProviderDragItem {
return createProviderDragItem(item.key)
}
function providerTitle(provider: ReservoirProviderItem) {
const status = props.statuses.find((item) => item.key === provider.key)
if (!status?.enabled) return `${provider.label} - disabled`
if (!status?.configured) return `${provider.label} - not configured`
return `Drag to assign ${provider.label} to a field`
}
</script>
<template>
<div class="flex flex-wrap gap-2">
<VueDraggable
:model-value="reservoirProviders"
item-key="key"
tag="div"
class="flex flex-wrap gap-2"
:group="{ name: PROVIDER_DND_GROUP, pull: 'clone', put: false }"
:sort="false"
:animation="150"
:clone="cloneProvider"
handle=".provider-reservoir-handle"
>
<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`
"
v-for="provider in reservoirProviders"
:key="provider.key"
:title="providerTitle(provider)"
class="flex items-center gap-1.5 h-7 px-3 rounded text-[10px] font-bold uppercase tracking-tight select-none transition-all shadow-xs"
:style="providerChipStyle(status.key, !(status.enabled && status.configured))"
:style="providerChipStyle(provider.key, !provider.usable)"
:class="
status.enabled && status.configured ? 'cursor-grab active:cursor-grabbing hover:scale-105 active:scale-95' : 'cursor-not-allowed opacity-40'
provider.usable
? 'provider-reservoir-handle cursor-grab active:cursor-grabbing hover:scale-105 active:scale-95'
: 'cursor-not-allowed opacity-40'
"
@dragstart="onDragStart(status.key, $event)"
>
<GripVertical v-if="status.enabled && status.configured" :size="10" class="shrink-0 -ml-1 opacity-50" />
<span>{{ PROVIDER_SHORT_LABELS[status.key] ?? status.key }}</span>
<GripVertical v-if="provider.usable" :size="10" class="shrink-0 -ml-1 opacity-50" />
<span>{{ PROVIDER_SHORT_LABELS[provider.key] ?? provider.key }}</span>
</div>
</div>
</VueDraggable>
</template>
@@ -1,14 +1,15 @@
import { ref } from 'vue'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import type { FieldPreference, LibraryMetadataPreferences, MetadataFetchPreferences, MetadataField } from '@projectx/types'
import type { FieldPreferenceOverrides, LibraryMetadataPreferences, MetadataFetchPreferences } from '@projectx/types'
import { ALL_METADATA_FIELDS } 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)
const savingLibrary = ref<number | null>(null)
async function fetchGlobal() {
loadingGlobal.value = true
@@ -47,26 +48,47 @@ export function useMetadataPreferences() {
}
}
async function saveFieldOverride(libraryId: number, field: MetadataField, pref: FieldPreference | null) {
const key = `${libraryId}:${field}`
savingField.value = key
async function saveLibraryDraft(libraryId: number, overrides: FieldPreferenceOverrides) {
savingLibrary.value = libraryId
try {
const url = `/api/v1/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,
const res = await api(`/api/v1/metadata-preferences/libraries/${libraryId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ overrides }),
})
if (res.ok || res.status === 204) {
if (res.ok) {
await fetchLibrary(libraryId)
toast.success('Library preferences saved')
} else {
toast.error('Failed to save field override')
toast.error('Failed to save library preferences')
}
} finally {
savingField.value = null
savingLibrary.value = null
}
}
async function resetGlobal() {
const res = await api('/api/v1/metadata-preferences/global', { method: 'DELETE' })
if (res.ok || res.status === 204) {
await fetchGlobal()
toast.success('Global preferences reset to system defaults')
} else {
toast.error('Failed to reset global preferences')
}
}
async function clearAllProviders(prefs: MetadataFetchPreferences) {
const fields: MetadataFetchPreferences['fields'] = { ...prefs.fields }
for (const field of ALL_METADATA_FIELDS) {
fields[field] = { ...prefs.fields[field], providers: [] }
}
const cleared: MetadataFetchPreferences = {
...prefs,
fields,
}
await saveGlobal(cleared)
}
async function resetLibrary(libraryId: number) {
const res = await api(`/api/v1/metadata-preferences/libraries/${libraryId}`, { method: 'DELETE' })
if (res.ok || res.status === 204) {
@@ -82,11 +104,13 @@ export function useMetadataPreferences() {
libraryPrefs,
loadingGlobal,
savingGlobal,
savingField,
savingLibrary,
fetchGlobal,
saveGlobal,
resetGlobal,
clearAllProviders,
fetchLibrary,
saveFieldOverride,
saveLibraryDraft,
resetLibrary,
}
}
@@ -0,0 +1,30 @@
import type { MetadataProviderKey } from '@projectx/types'
export interface ProviderDragItem {
key: MetadataProviderKey
dragId: string
}
export const PROVIDER_DND_GROUP = 'metadata-preferences-provider'
let providerDragId = 0
export function createProviderDragItem(key: MetadataProviderKey): ProviderDragItem {
providerDragId += 1
return { key, dragId: `${key}:${providerDragId}` }
}
export function toProviderDragItems(providers: MetadataProviderKey[]): ProviderDragItem[] {
return providers.map((key) => createProviderDragItem(key))
}
export function normalizeProviderOrder(items: ProviderDragItem[]): MetadataProviderKey[] {
const next: MetadataProviderKey[] = []
const seen = new Set<MetadataProviderKey>()
for (const item of items) {
if (seen.has(item.key)) continue
seen.add(item.key)
next.push(item.key)
}
return next
}