d6eba8e4bd
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
417 lines
13 KiB
Vue
417 lines
13 KiB
Vue
<template>
|
|
<v-container fluid class="py-6">
|
|
<div class="fc-subs__bar">
|
|
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
|
Add subscription
|
|
</v-btn>
|
|
<v-btn variant="outlined" prepend-icon="mdi-account-plus" @click="showArtistDialog = true">
|
|
New artist
|
|
</v-btn>
|
|
<v-spacer />
|
|
<v-text-field
|
|
v-model="search"
|
|
density="compact" variant="outlined" hide-details clearable
|
|
prepend-inner-icon="mdi-magnify"
|
|
placeholder="Search subscriptions"
|
|
style="max-width: 320px"
|
|
/>
|
|
</div>
|
|
|
|
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mt-4">
|
|
{{ String(store.error) }}
|
|
</v-alert>
|
|
|
|
<div v-if="store.loading && groups.length === 0" class="fc-subs__loading">
|
|
<v-progress-circular indeterminate color="accent" size="36" />
|
|
</div>
|
|
|
|
<div v-else-if="filteredGroups.length === 0" class="fc-subs__empty">
|
|
<p v-if="groups.length === 0">No subscriptions yet. Add your first artist.</p>
|
|
<p v-else>No subscriptions match "{{ search }}".</p>
|
|
</div>
|
|
|
|
<v-card v-else class="fc-subs__card" variant="outlined">
|
|
<v-data-table
|
|
:headers="headers"
|
|
:items="filteredGroups"
|
|
item-value="key"
|
|
v-model:expanded="expanded"
|
|
:items-per-page="50"
|
|
:items-per-page-options="ITEMS_PER_PAGE_OPTIONS"
|
|
density="comfortable"
|
|
hover
|
|
show-expand
|
|
@click:row="onRowClick"
|
|
>
|
|
<template #item.name="{ item }">
|
|
<span class="fc-subs__name">{{ item.artist.name }}</span>
|
|
</template>
|
|
|
|
<template #item.sources_count="{ item }">
|
|
<v-chip size="x-small" variant="tonal" label>
|
|
{{ item.sources.length }} source{{ item.sources.length === 1 ? '' : 's' }}
|
|
</v-chip>
|
|
</template>
|
|
|
|
<template #item.health="{ item }">
|
|
<SourceHealthDot
|
|
v-if="item.worstSource"
|
|
:source="item.worstSource"
|
|
:warning-threshold="failureThreshold"
|
|
/>
|
|
<span v-else class="fc-subs__zero">—</span>
|
|
</template>
|
|
|
|
<template #item.last_activity="{ item }">
|
|
<span class="fc-subs__when">
|
|
{{ formatRelative(item.lastActivity) }}
|
|
</span>
|
|
</template>
|
|
|
|
<template #item.actions="{ item }">
|
|
<v-btn
|
|
icon size="small" variant="text"
|
|
:loading="anyChecking(item.sources)"
|
|
@click.stop="checkAll(item)"
|
|
>
|
|
<v-icon>mdi-refresh</v-icon>
|
|
<v-tooltip activator="parent" location="top">Check all sources</v-tooltip>
|
|
</v-btn>
|
|
<v-btn
|
|
icon size="small" variant="text"
|
|
@click.stop="openAddSource(item.artist)"
|
|
>
|
|
<v-icon>mdi-plus</v-icon>
|
|
<v-tooltip activator="parent" location="top">Add source</v-tooltip>
|
|
</v-btn>
|
|
<v-btn
|
|
icon size="small" variant="text"
|
|
:to="`/posts?artist_id=${item.artist.id}`"
|
|
@click.stop
|
|
>
|
|
<v-icon>mdi-rss</v-icon>
|
|
<v-tooltip activator="parent" location="top">View posts</v-tooltip>
|
|
</v-btn>
|
|
<v-btn
|
|
icon size="small" variant="text"
|
|
:to="`/artist/${item.artist.slug}`"
|
|
@click.stop
|
|
>
|
|
<v-icon>mdi-account</v-icon>
|
|
<v-tooltip activator="parent" location="top">Open artist page</v-tooltip>
|
|
</v-btn>
|
|
</template>
|
|
|
|
<template #expanded-row="{ columns, item }">
|
|
<tr class="fc-subs__sources-row">
|
|
<td :colspan="columns.length" class="fc-subs__sources-cell">
|
|
<v-table density="compact" class="fc-subs__sources-table">
|
|
<thead>
|
|
<tr>
|
|
<th></th>
|
|
<th>Platform</th>
|
|
<th>URL</th>
|
|
<th>Enabled</th>
|
|
<th>Last check</th>
|
|
<th>Next check</th>
|
|
<th>Errors</th>
|
|
<th class="text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<SourceRow
|
|
v-for="s in item.sources" :key="s.id" :source="s"
|
|
:checking="store.checkingIds.has(s.id)"
|
|
:warning-threshold="failureThreshold"
|
|
@edit="openEditSource"
|
|
@remove="removeSource"
|
|
@toggle="toggleSourceEnabled"
|
|
@check="onCheck"
|
|
/>
|
|
<tr v-if="item.sources.length === 0">
|
|
<td colspan="8" class="fc-subs__sources-empty">
|
|
No sources yet. Click + to add one.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</v-table>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</v-data-table>
|
|
</v-card>
|
|
|
|
<SourceFormDialog
|
|
v-model="showSourceDialog"
|
|
:source="editingSource"
|
|
:initial-artist="editingArtist"
|
|
@saved="onSourceSaved"
|
|
/>
|
|
<ArtistCreateDialog v-model="showArtistDialog" @created="onArtistCreated" />
|
|
</v-container>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useSourcesStore } from '../stores/sources.js'
|
|
import { usePlatformsStore } from '../stores/platforms.js'
|
|
import { useImportStore } from '../stores/import.js'
|
|
import SourceRow from '../components/subscriptions/SourceRow.vue'
|
|
import SourceHealthDot from '../components/subscriptions/SourceHealthDot.vue'
|
|
import SourceFormDialog from '../components/subscriptions/SourceFormDialog.vue'
|
|
import ArtistCreateDialog from '../components/subscriptions/ArtistCreateDialog.vue'
|
|
|
|
const ITEMS_PER_PAGE_OPTIONS = [
|
|
{ value: 25, title: '25' },
|
|
{ value: 50, title: '50' },
|
|
{ value: 100, title: '100' },
|
|
{ value: -1, title: 'All' },
|
|
]
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const store = useSourcesStore()
|
|
const platformsStore = usePlatformsStore()
|
|
const importStore = useImportStore()
|
|
|
|
const search = ref('')
|
|
const expanded = ref([])
|
|
const showSourceDialog = ref(false)
|
|
const editingSource = ref(null)
|
|
const editingArtist = ref(null)
|
|
const showArtistDialog = ref(false)
|
|
|
|
const artistFilter = computed(() => {
|
|
const raw = route.query.artist_id
|
|
return raw == null ? null : Number(raw)
|
|
})
|
|
|
|
const failureThreshold = computed(() =>
|
|
importStore.settings?.download_failure_warning_threshold ?? 5
|
|
)
|
|
|
|
async function refresh() {
|
|
await store.loadAll()
|
|
await platformsStore.loadAll()
|
|
if (!importStore.settings) await importStore.loadSettings()
|
|
}
|
|
|
|
onMounted(() => {
|
|
refresh()
|
|
if (artistFilter.value != null) {
|
|
// Pre-expand the row that the deep-link refers to.
|
|
expanded.value = [`artist-${artistFilter.value}`]
|
|
}
|
|
})
|
|
watch(() => route.query.artist_id, refresh)
|
|
|
|
const headers = [
|
|
{ title: 'Subscription', key: 'name', sortable: true, align: 'start' },
|
|
{ title: 'Sources', key: 'sources_count', sortable: true, align: 'start', width: 110 },
|
|
{ title: 'Health', key: 'health', sortable: false, align: 'start', width: 80 },
|
|
{ title: 'Last activity',key: 'last_activity', sortable: true, align: 'start', width: 140 },
|
|
{ title: 'Actions', key: 'actions', sortable: false, align: 'end', width: 200 },
|
|
]
|
|
|
|
const groups = computed(() => {
|
|
const all = store.sourcesByArtistGrouped()
|
|
return all.map(g => {
|
|
const worstSource = pickWorstSource(g.sources, failureThreshold.value)
|
|
const lastActivity = pickLastActivity(g.sources)
|
|
return {
|
|
key: `artist-${g.artist.id}`,
|
|
artist: g.artist,
|
|
sources: g.sources,
|
|
sources_count: g.sources.length,
|
|
worstSource,
|
|
lastActivity,
|
|
name: g.artist.name, // for sortable column
|
|
last_activity: lastActivity ?? '', // for sortable column
|
|
}
|
|
})
|
|
})
|
|
|
|
const filteredGroups = computed(() => {
|
|
let arr = groups.value
|
|
if (artistFilter.value != null) {
|
|
arr = arr.filter(g => g.artist.id === artistFilter.value)
|
|
}
|
|
const q = search.value?.trim().toLowerCase()
|
|
if (q) {
|
|
arr = arr.filter(g =>
|
|
g.artist.name.toLowerCase().includes(q)
|
|
|| g.sources.some(s => (s.url || '').toLowerCase().includes(q)
|
|
|| (s.platform || '').toLowerCase().includes(q))
|
|
)
|
|
}
|
|
return arr
|
|
})
|
|
|
|
function pickLastActivity(sources) {
|
|
let max = null
|
|
for (const s of sources) {
|
|
if (s.last_checked_at && (!max || s.last_checked_at > max)) max = s.last_checked_at
|
|
}
|
|
return max
|
|
}
|
|
|
|
function pickWorstSource(sources, threshold) {
|
|
// Health order (worst → best): critical, warning, healthy, unchecked.
|
|
// Picks the source with the worst level so the row's dot reflects the
|
|
// worst-case state. Within a level, the first is fine.
|
|
if (!sources || sources.length === 0) return null
|
|
function level(s) {
|
|
if (!s.last_checked_at) return 0 // unchecked
|
|
const f = s.consecutive_failures || 0
|
|
if (f === 0) return 1 // healthy
|
|
if (f < threshold) return 2 // warning
|
|
return 3 // critical
|
|
}
|
|
return sources.reduce((worst, s) =>
|
|
level(s) > level(worst) ? s : worst,
|
|
sources[0],
|
|
)
|
|
}
|
|
|
|
function formatRelative(iso) {
|
|
if (!iso) return 'Never'
|
|
const then = new Date(iso).getTime()
|
|
const diff = (Date.now() - then) / 1000
|
|
if (diff < 60) return `${Math.floor(diff)}s ago`
|
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
|
return `${Math.floor(diff / 86400)}d ago`
|
|
}
|
|
|
|
function onRowClick(_evt, { item, internalItem }) {
|
|
// Toggle expansion on row click (in addition to the chevron).
|
|
const key = item.key
|
|
const idx = expanded.value.indexOf(key)
|
|
if (idx === -1) expanded.value = [...expanded.value, key]
|
|
else expanded.value = expanded.value.filter(k => k !== key)
|
|
}
|
|
|
|
function openAddSource(artist) {
|
|
editingSource.value = null
|
|
editingArtist.value = artist
|
|
showSourceDialog.value = true
|
|
}
|
|
|
|
function openEditSource(source) {
|
|
editingSource.value = source
|
|
editingArtist.value = { id: source.artist_id, name: source.artist_name, slug: source.artist_slug }
|
|
showSourceDialog.value = true
|
|
}
|
|
|
|
async function removeSource(source) {
|
|
await store.remove(source.id, source.artist_id)
|
|
await refresh()
|
|
}
|
|
|
|
async function toggleSourceEnabled({ source, enabled }) {
|
|
await store.update(source.id, { enabled }, source.artist_id)
|
|
await refresh()
|
|
}
|
|
|
|
async function onSourceSaved() {
|
|
showSourceDialog.value = false
|
|
await refresh()
|
|
}
|
|
|
|
function onArtistCreated(artist) {
|
|
showArtistDialog.value = false
|
|
openAddSource(artist)
|
|
}
|
|
|
|
async function onCheck(source) {
|
|
try {
|
|
const body = await store.checkNow(source.id)
|
|
globalThis.window?.__fcToast?.({
|
|
text: `Check enqueued (event #${body.download_event_id})`,
|
|
type: 'success',
|
|
})
|
|
} catch (e) {
|
|
if (e?.body?.download_event_id) {
|
|
globalThis.window?.__fcToast?.({
|
|
text: 'Already running — see Downloads',
|
|
type: 'info',
|
|
})
|
|
router.push({ path: '/downloads', query: { source_id: source.id } })
|
|
} else {
|
|
globalThis.window?.__fcToast?.({
|
|
text: `Check failed: ${e?.detail || e?.message || e}`,
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
async function checkAll(group) {
|
|
let ok = 0
|
|
let conflict = 0
|
|
for (const s of group.sources) {
|
|
if (!s.enabled) continue
|
|
try {
|
|
await store.checkNow(s.id)
|
|
ok += 1
|
|
} catch (e) {
|
|
if (e?.body?.download_event_id) conflict += 1
|
|
}
|
|
}
|
|
const parts = []
|
|
if (ok) parts.push(`${ok} queued`)
|
|
if (conflict) parts.push(`${conflict} already running`)
|
|
globalThis.window?.__fcToast?.({
|
|
text: parts.join(', ') || 'Nothing to check (no enabled sources)',
|
|
type: 'info',
|
|
})
|
|
}
|
|
|
|
function anyChecking(sources) {
|
|
return sources.some(s => store.checkingIds.has(s.id))
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-subs__bar {
|
|
display: flex; gap: 0.75rem; align-items: center;
|
|
padding-bottom: 1rem;
|
|
}
|
|
.fc-subs__loading, .fc-subs__empty {
|
|
display: flex; justify-content: center; padding: 2rem;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-subs__card {
|
|
background: rgb(var(--v-theme-surface));
|
|
}
|
|
.fc-subs__name {
|
|
font-weight: 600;
|
|
}
|
|
.fc-subs__when {
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
white-space: nowrap;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.fc-subs__zero {
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
opacity: 0.6;
|
|
}
|
|
.fc-subs__sources-row td {
|
|
padding: 0 !important;
|
|
background: rgb(var(--v-theme-surface-light));
|
|
}
|
|
.fc-subs__sources-cell {
|
|
padding-left: 2rem !important;
|
|
border-top: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.15);
|
|
}
|
|
.fc-subs__sources-table {
|
|
background: transparent !important;
|
|
}
|
|
.fc-subs__sources-empty {
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
text-align: center;
|
|
padding: 1rem;
|
|
}
|
|
</style>
|