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:
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="fc-dl__top">
|
||||
<DownloadStatChips :stats="store.stats" />
|
||||
<v-spacer />
|
||||
<v-btn variant="text" icon @click="refresh">
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Refresh</v-tooltip>
|
||||
</v-btn>
|
||||
<MaintenanceMenu @refresh="refresh" />
|
||||
</div>
|
||||
|
||||
<DownloadsFilterPopover v-model="filterModel" class="fc-dl__filter" />
|
||||
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
|
||||
{{ String(store.error) }}
|
||||
</v-alert>
|
||||
|
||||
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.events.length === 0" class="fc-dl__empty">
|
||||
<p>No download events match the current filter.</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<DownloadEventRow
|
||||
v-for="e in filteredEvents" :key="e.id" :event="e"
|
||||
@open="openDetail"
|
||||
/>
|
||||
<div class="fc-dl__sentinel">
|
||||
<v-btn v-if="store.hasMore" variant="text" @click="store.loadMore()" :loading="store.loading">
|
||||
Load more
|
||||
</v-btn>
|
||||
<span v-else class="text-caption" style="opacity: 0.5">No more events.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DownloadDetailModal
|
||||
:event="store.selected"
|
||||
@close="store.closeDetail()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useDownloadsStore } from '../../stores/downloads.js'
|
||||
import DownloadEventRow from '../downloads/DownloadEventRow.vue'
|
||||
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
|
||||
import DownloadStatChips from './DownloadStatChips.vue'
|
||||
import MaintenanceMenu from './MaintenanceMenu.vue'
|
||||
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useDownloadsStore()
|
||||
const filterModel = ref({ ...store.filter })
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([
|
||||
store.loadFirst(),
|
||||
store.loadStats(24),
|
||||
])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.source_id) {
|
||||
filterModel.value = { ...filterModel.value, source_id: Number(route.query.source_id) }
|
||||
}
|
||||
refresh()
|
||||
})
|
||||
|
||||
// Client-side date filter on the loaded page (avoids a backend round-trip
|
||||
// for the date pickers; the existing /api/downloads endpoint can grow
|
||||
// these as proper query params later if a UX need shows up).
|
||||
const filteredEvents = computed(() => {
|
||||
let arr = store.events
|
||||
const from = filterModel.value.from_date
|
||||
const to = filterModel.value.to_date
|
||||
if (from) {
|
||||
const fromTs = new Date(from).getTime()
|
||||
arr = arr.filter((e) => new Date(e.started_at).getTime() >= fromTs)
|
||||
}
|
||||
if (to) {
|
||||
const toTs = new Date(to).getTime() + 24 * 3600 * 1000 - 1
|
||||
arr = arr.filter((e) => new Date(e.started_at).getTime() <= toTs)
|
||||
}
|
||||
return arr
|
||||
})
|
||||
|
||||
watch(filterModel, async (m) => {
|
||||
await store.applyFilter({
|
||||
status: m.status,
|
||||
source_id: m.source_id || null,
|
||||
from_date: m.from_date,
|
||||
to_date: m.to_date,
|
||||
})
|
||||
await store.loadStats(24)
|
||||
}, { deep: true })
|
||||
|
||||
async function openDetail(id) {
|
||||
await store.loadOne(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-dl__top {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fc-dl__filter { margin-bottom: 12px; }
|
||||
.fc-dl__loading, .fc-dl__empty {
|
||||
display: flex; justify-content: center; padding: 3rem 0;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-dl__sentinel {
|
||||
display: flex; justify-content: center; padding: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user