feat(fc3a): SourceFormDialog (structured+JSON tabs) + ArtistCreateDialog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" max-width="640"
|
||||
@update:model-value="$emit('update:modelValue', $event)">
|
||||
<v-card>
|
||||
<v-card-title>{{ source ? 'Edit source' : 'Add subscription' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- Artist picker (only when adding without a pre-selected artist) -->
|
||||
<div v-if="!source && !initialArtist" class="mb-3">
|
||||
<v-combobox
|
||||
v-model="artistChoice"
|
||||
:items="artistMatches"
|
||||
item-title="name" item-value="id"
|
||||
label="Artist (type to search or create)"
|
||||
:loading="artistLoading"
|
||||
return-object hide-no-data
|
||||
@update:search="onArtistSearch"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="initialArtist && !source" class="text-caption mb-3">
|
||||
Adding source for <strong>{{ initialArtist.name }}</strong>
|
||||
</div>
|
||||
|
||||
<v-select v-model="platform" :items="store.platforms" label="Platform" />
|
||||
<v-text-field v-model="url" label="URL" :error-messages="urlError" />
|
||||
<v-switch v-model="enabled" label="Enabled" color="accent" hide-details />
|
||||
|
||||
<v-tabs v-model="configTab" class="mt-4">
|
||||
<v-tab value="structured">Structured</v-tab>
|
||||
<v-tab value="advanced">Advanced JSON</v-tab>
|
||||
</v-tabs>
|
||||
<v-window v-model="configTab" class="mt-2">
|
||||
<v-window-item value="structured">
|
||||
<v-switch
|
||||
v-model="structuredVideos"
|
||||
label="Include videos" color="accent" hide-details
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="structuredSince" label="Skip posts older than (YYYY-MM-DD)"
|
||||
placeholder="2024-01-01" hide-details class="mt-2"
|
||||
/>
|
||||
<p class="text-caption mt-2" style="opacity: 0.75">
|
||||
More per-platform fields land here over time. Use Advanced JSON for everything else.
|
||||
</p>
|
||||
</v-window-item>
|
||||
<v-window-item value="advanced">
|
||||
<v-textarea
|
||||
v-model="jsonText" :error-messages="jsonError"
|
||||
label="config_overrides (JSON object)"
|
||||
auto-grow rows="6" spellcheck="false"
|
||||
/>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="$emit('update:modelValue', false)">Cancel</v-btn>
|
||||
<v-btn color="accent" variant="flat" :loading="busy" :disabled="!canSave" @click="save">
|
||||
Save
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useSourcesStore } from '../../stores/sources.js'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
source: { type: Object, default: null },
|
||||
initialArtist: { type: Object, default: null },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'saved'])
|
||||
const store = useSourcesStore()
|
||||
|
||||
const artistChoice = ref(null)
|
||||
const artistMatches = ref([])
|
||||
const artistLoading = ref(false)
|
||||
|
||||
const platform = ref('patreon')
|
||||
const url = ref('')
|
||||
const enabled = ref(true)
|
||||
const urlError = ref('')
|
||||
|
||||
const configTab = ref('structured')
|
||||
const structuredVideos = ref(true)
|
||||
const structuredSince = ref('')
|
||||
const jsonText = ref('{}')
|
||||
const jsonError = ref('')
|
||||
|
||||
const busy = ref(false)
|
||||
|
||||
// Sync config_overrides between the two views.
|
||||
const config = computed(() => {
|
||||
const out = {}
|
||||
if (!structuredVideos.value) out.videos = false
|
||||
if (structuredSince.value) out.since = structuredSince.value
|
||||
return out
|
||||
})
|
||||
|
||||
watch([structuredVideos, structuredSince], () => {
|
||||
if (configTab.value === 'structured') {
|
||||
jsonText.value = JSON.stringify(config.value, null, 2)
|
||||
jsonError.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(jsonText, (txt) => {
|
||||
if (configTab.value !== 'advanced') return
|
||||
try {
|
||||
const parsed = JSON.parse(txt || '{}')
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
|
||||
jsonError.value = 'Must be a JSON object'
|
||||
return
|
||||
}
|
||||
jsonError.value = ''
|
||||
// Reflect recognized keys into the structured view.
|
||||
structuredVideos.value = parsed.videos !== false
|
||||
structuredSince.value = parsed.since ?? ''
|
||||
} catch {
|
||||
jsonError.value = 'Invalid JSON'
|
||||
}
|
||||
})
|
||||
|
||||
const canSave = computed(() => !jsonError.value && !!url.value.trim())
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (!open) return
|
||||
urlError.value = ''; jsonError.value = ''
|
||||
await store.loadPlatforms()
|
||||
if (props.source) {
|
||||
platform.value = props.source.platform
|
||||
url.value = props.source.url
|
||||
enabled.value = props.source.enabled
|
||||
const co = props.source.config_overrides || {}
|
||||
structuredVideos.value = co.videos !== false
|
||||
structuredSince.value = co.since ?? ''
|
||||
jsonText.value = JSON.stringify(co, null, 2)
|
||||
artistChoice.value = { id: props.source.artist_id, name: props.source.artist_name }
|
||||
} else {
|
||||
platform.value = store.platforms[0] || 'patreon'
|
||||
url.value = ''; enabled.value = true
|
||||
structuredVideos.value = true; structuredSince.value = ''
|
||||
jsonText.value = '{}'
|
||||
artistChoice.value = props.initialArtist
|
||||
? { id: props.initialArtist.id, name: props.initialArtist.name }
|
||||
: null
|
||||
}
|
||||
})
|
||||
|
||||
async function onArtistSearch(q) {
|
||||
if (!q || (typeof q !== 'string')) return
|
||||
artistLoading.value = true
|
||||
try {
|
||||
artistMatches.value = await store.autocompleteArtist(q)
|
||||
} finally {
|
||||
artistLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function _parseConfig() {
|
||||
if (configTab.value === 'structured') return Object.keys(config.value).length ? config.value : null
|
||||
const parsed = JSON.parse(jsonText.value || '{}')
|
||||
return Object.keys(parsed).length ? parsed : null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
urlError.value = ''
|
||||
if (!url.value.trim()) { urlError.value = 'URL is required'; return }
|
||||
let configOut
|
||||
try { configOut = _parseConfig() } catch { jsonError.value = 'Invalid JSON'; return }
|
||||
|
||||
busy.value = true
|
||||
try {
|
||||
if (props.source) {
|
||||
await store.update(props.source.id, {
|
||||
platform: platform.value, url: url.value.trim(),
|
||||
enabled: enabled.value, config_overrides: configOut,
|
||||
}, props.source.artist_id)
|
||||
} else {
|
||||
let artistId
|
||||
if (props.initialArtist) artistId = props.initialArtist.id
|
||||
else if (artistChoice.value && typeof artistChoice.value === 'object') artistId = artistChoice.value.id
|
||||
else if (typeof artistChoice.value === 'string') {
|
||||
const { artist } = await store.findOrCreateArtist(artistChoice.value)
|
||||
artistId = artist.id
|
||||
} else {
|
||||
urlError.value = 'Pick or create an artist first'; busy.value = false; return
|
||||
}
|
||||
await store.create({
|
||||
artist_id: artistId, platform: platform.value, url: url.value.trim(),
|
||||
enabled: enabled.value, config_overrides: configOut,
|
||||
})
|
||||
}
|
||||
emit('saved')
|
||||
} catch (e) {
|
||||
if (e?.body?.error === 'duplicate') {
|
||||
urlError.value = `Duplicate (existing source id ${e.body.existing_id})`
|
||||
} else {
|
||||
urlError.value = String(e?.detail || e?.message || e)
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user