feat(subs-hub): collapse /credentials + /downloads into /subscriptions hub with three GS-style subtabs

Replaces the three top-level routes with a single `/subscriptions` parent
owning the whole download-pipeline domain. Internal tab state via `?tab=`
query param, mirroring ArtistView's pattern. TopNav auto-drops the two
removed entries (route-driven via meta.title). Bookmark-safe redirects
from `/credentials` and `/downloads` route into the appropriate subtab.

**Subtab 1 — Subscriptions (default).** Carries over the existing
artist-grouped expandable table; adds (a) status filter dropdown, (b)
bulk-select column with Enable/Disable/Delete-all actions, (c) GS-style
color-coded `PlatformChip` per distinct platform in the collapsed row.
Reuses SourceRow, SourceHealthDot, SourceFormDialog, ArtistCreateDialog.

**Subtab 2 — Downloads.** Full GS dashboard. Five colored stat chips up
top (Queued/Running/Completed/Failed/Skipped, sourced from new
`GET /api/downloads/stats?window_hours=`). Popover-style filter UI
(Status/Source/FromDate/ToDate) with active-filter pills below.
Maintenance menu wraps existing /api/import/retry-failed and
/api/import/clear-stuck endpoints; Export-failed-logs item disabled with
a "v2" tooltip. Per-row Retry preserved via existing DownloadEventRow.

**Subtab 3 — Settings.** Four sections: ExtensionKeyBar (top), GS-style
per-platform CredentialCard grid (md=6 v-row/v-col, dashed border if
unset / accent border if set, expandable how-to panel), Downloader card
(rate limit, validate_files), Schedule defaults card (default interval,
event retention, failure warning threshold). The Downloader and Schedule
sections were extracted out of components/settings/ImportFiltersForm.vue
— SettingsView's Import tab now owns only image-import filters.

**Backend:** new `GET /api/downloads/stats` returns
{pending, running, ok, error, skipped} count grouped by status over the
configurable window. Status keys stay raw from the ENUM; UI does the
display-label mapping. Two integration tests pin the response shape +
window_hours validation.

**Util:** `frontend/src/utils/platformColor.js` — single source of truth
for the six platforms' color + icon + label, mirroring GS's palette
(patreon=red mdi-patreon, subscribestar=amber mdi-star,
hentaifoundry=purple mdi-palette, discord=indigo mdi-discord,
pixiv=blue mdi-alpha-p-box, deviantart=green mdi-deviantart). Unknown
platform falls back to grey + mdi-web.

Deferred (explicit non-goals): subscription import/export, "Trigger Due
Now" scheduler-tick button (needs new backend endpoint), Export Failed
Logs CSV dump.
This commit is contained in:
2026-05-27 13:02:24 -04:00
parent 8675f105ad
commit 9322c984fd
17 changed files with 1395 additions and 616 deletions
+47 -392
View File
@@ -1,416 +1,71 @@
<template>
<v-container fluid class="pt-2 pb-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-tabs
v-model="tab"
align-tabs="start"
color="accent"
density="compact"
class="fc-subs-tabs"
>
<v-tab value="subscriptions">
<v-icon start>mdi-account-multiple-check</v-icon>
Subscriptions
</v-tab>
<v-tab value="downloads">
<v-icon start>mdi-cloud-download</v-icon>
Downloads
</v-tab>
<v-tab value="settings">
<v-icon start>mdi-cog</v-icon>
Settings
</v-tab>
</v-tabs>
<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-window v-model="tab" class="mt-4">
<v-window-item value="subscriptions">
<SubscriptionsTab />
</v-window-item>
<v-window-item value="downloads">
<DownloadsTab />
</v-window-item>
<v-window-item value="settings">
<SettingsTab />
</v-window-item>
</v-window>
</v-container>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { 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' },
]
import SubscriptionsTab from '../components/subscriptions/SubscriptionsTab.vue'
import DownloadsTab from '../components/subscriptions/DownloadsTab.vue'
import SettingsTab from '../components/subscriptions/SettingsTab.vue'
const VALID_TABS = ['subscriptions', 'downloads', 'settings']
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
const tab = ref(
VALID_TABS.includes(route.query.tab) ? route.query.tab : 'subscriptions',
)
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
}
})
watch(tab, (t) => {
if (route.query.tab === t) return
router.replace({ query: { ...route.query, tab: t } })
})
const filteredGroups = computed(() => {
let arr = groups.value
if (artistFilter.value != null) {
arr = arr.filter(g => g.artist.id === artistFilter.value)
watch(() => route.query.tab, (q) => {
if (q && VALID_TABS.includes(q) && tab.value !== q) {
tab.value = q
}
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;
.fc-subs-tabs {
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
}
</style>