mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): batch library overrides and improve comic refresh
This commit is contained in:
@@ -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)"
|
||||
/>
|
||||
|
||||
+2
-4
@@ -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
|
||||
|
||||
+55
-38
@@ -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>
|
||||
|
||||
+154
-49
@@ -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>
|
||||
|
||||
+19
-6
@@ -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>
|
||||
|
||||
+49
-22
@@ -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>
|
||||
|
||||
+39
-15
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user