408971a798
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
220 lines
7.3 KiB
Vue
220 lines
7.3 KiB
Vue
<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="platformsItems"
|
|
item-value="value"
|
|
item-title="title"
|
|
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'
|
|
import { usePlatformsStore } from '../../stores/platforms.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 platformsStore = usePlatformsStore()
|
|
|
|
const platformsItems = computed(() =>
|
|
platformsStore.list.map(p => ({ value: p.key, title: p.name }))
|
|
)
|
|
|
|
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 platformsStore.loadAll()
|
|
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 = platformsStore.list[0]?.key || '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>
|