feat(downloads-tab): A+B dashboard improvements — row restyle + date-grouped sections with failed-pinned
Operator-flagged 2026-05-27: the Downloads subtab "doesn't feel like a dashboard" — status was a tiny mdi icon at the far left, platform chip was neutral-tonal, errors were plain orange text floating on the right, and all 28 rows from the same hour visually had the same priority. **Row restyle (A):** - 4px colored left-edge bar by status (success/error/info/warning/grey) — visually scannable at the edge without parsing the chip text - Status chip with text label (Completed/Failed/Running/Queued/Skipped) + leading icon, tonal-colored. Replaces the bare mdi-icon. - Platform chip swapped to the color-coded subscriptions/PlatformChip (Patreon=red mdi-patreon, SubscribeStar=amber, HentaiFoundry=purple, Discord=indigo, Pixiv=blue, DeviantArt=green). - File count: tonal info chip when > 0, dim middle-dot when 0 (so scheduled "no-change" scans don't dominate the column visually). - Error: red tonal pill chip with leading icon, truncated to 60 chars with full text in the title tooltip. Replaces plain text. - Per-row actions (hidden at 50% opacity, fade to full on row hover): Retry (only when status=error AND source_id known — hits POST /api/sources/<id>/check via the existing sources.checkNow), Details (opens the detail modal), Open artist (navigates to the artist page). Clicks stop-propagation so they don't bubble to the row click. **Date-grouped sections (B):** - Events are bucketed into four sections: Today / Yesterday / Last 7 days / Earlier. Empty buckets are skipped. Buckets boundaries are computed against the operator's local-time start-of-day so "Today" matches their intuition. - Each section has a collapsible header with a row-count chip + a red "failed in this section" chip when any failures are in scope. - Within each section, status='error' rows are pinned to the top (operator's eye lands on failures first; successful scans flow below). - Collapsed state persists across refresh within the SubscriptionsView lifetime (reactive object, default all-expanded). DownloadEventRow grid widened to accommodate the status chip + actions column. PolyMasonry-style ellipsis on the artist link prevents long names from breaking the layout. No new endpoints; the Retry path reuses the existing /api/sources/<id>/check flow (the source-check endpoint was already in place, just not wired into a per-row button).
This commit is contained in:
@@ -20,15 +20,44 @@
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.events.length === 0" class="fc-dl__empty">
|
||||
<div v-else-if="filteredEvents.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"
|
||||
/>
|
||||
<section
|
||||
v-for="g in groups" :key="g.key"
|
||||
class="fc-dl__group"
|
||||
>
|
||||
<header
|
||||
class="fc-dl__group-head"
|
||||
role="button" tabindex="0"
|
||||
@click="toggle(g.key)" @keydown.enter="toggle(g.key)"
|
||||
>
|
||||
<v-icon size="small" class="fc-dl__group-chev">
|
||||
{{ collapsed[g.key] ? 'mdi-chevron-right' : 'mdi-chevron-down' }}
|
||||
</v-icon>
|
||||
<span class="fc-dl__group-label">{{ g.label }}</span>
|
||||
<span class="fc-dl__group-counts">
|
||||
<v-chip
|
||||
v-if="g.failedCount > 0"
|
||||
size="x-small" color="error" variant="tonal"
|
||||
prepend-icon="mdi-alert-circle"
|
||||
>{{ g.failedCount }}</v-chip>
|
||||
<v-chip size="x-small" variant="tonal">
|
||||
{{ g.items.length }}
|
||||
{{ g.items.length === 1 ? 'event' : 'events' }}
|
||||
</v-chip>
|
||||
</span>
|
||||
</header>
|
||||
<div v-if="!collapsed[g.key]" class="fc-dl__group-body">
|
||||
<DownloadEventRow
|
||||
v-for="e in g.items" :key="e.id" :event="e"
|
||||
@open="openDetail"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="fc-dl__sentinel">
|
||||
<v-btn v-if="store.hasMore" variant="text" @click="store.loadMore()" :loading="store.loading">
|
||||
Load more
|
||||
@@ -45,7 +74,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useDownloadsStore } from '../../stores/downloads.js'
|
||||
@@ -59,6 +88,16 @@ const route = useRoute()
|
||||
const store = useDownloadsStore()
|
||||
const filterModel = ref({ ...store.filter })
|
||||
|
||||
// Each group's collapsed state persists across refreshes for the
|
||||
// lifetime of the SubscriptionsView (operator-friendly default: all
|
||||
// expanded; collapse what you don't care about right now).
|
||||
const collapsed = reactive({
|
||||
today: false, yesterday: false, week: false, earlier: false,
|
||||
})
|
||||
function toggle(key) {
|
||||
collapsed[key] = !collapsed[key]
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([
|
||||
store.loadFirst(),
|
||||
@@ -91,6 +130,48 @@ const filteredEvents = computed(() => {
|
||||
return arr
|
||||
})
|
||||
|
||||
// Group events by relative date bucket and pin failed runs to the
|
||||
// top of each bucket. Buckets boundaries are computed against the
|
||||
// operator's local-time start-of-day so "Today" matches their
|
||||
// intuition regardless of the event's stored UTC timestamp.
|
||||
const groups = computed(() => {
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(), now.getMonth(), now.getDate(),
|
||||
).getTime()
|
||||
const startOfYesterday = startOfToday - 24 * 3600 * 1000
|
||||
const startOfWeek = startOfToday - 7 * 24 * 3600 * 1000
|
||||
|
||||
const buckets = { today: [], yesterday: [], week: [], earlier: [] }
|
||||
for (const e of filteredEvents.value) {
|
||||
const t = new Date(e.started_at).getTime()
|
||||
if (t >= startOfToday) buckets.today.push(e)
|
||||
else if (t >= startOfYesterday) buckets.yesterday.push(e)
|
||||
else if (t >= startOfWeek) buckets.week.push(e)
|
||||
else buckets.earlier.push(e)
|
||||
}
|
||||
|
||||
function withFailedPinned(items) {
|
||||
const fail = items.filter((e) => e.status === 'error')
|
||||
const rest = items.filter((e) => e.status !== 'error')
|
||||
return [...fail, ...rest]
|
||||
}
|
||||
|
||||
const meta = [
|
||||
{ key: 'today', label: 'Today' },
|
||||
{ key: 'yesterday', label: 'Yesterday' },
|
||||
{ key: 'week', label: 'Last 7 days' },
|
||||
{ key: 'earlier', label: 'Earlier' },
|
||||
]
|
||||
return meta
|
||||
.map(({ key, label }) => {
|
||||
const items = withFailedPinned(buckets[key])
|
||||
const failedCount = items.filter((e) => e.status === 'error').length
|
||||
return { key, label, items, failedCount }
|
||||
})
|
||||
.filter((g) => g.items.length > 0)
|
||||
})
|
||||
|
||||
watch(filterModel, async (m) => {
|
||||
await store.applyFilter({
|
||||
status: m.status,
|
||||
@@ -120,4 +201,29 @@ async function openDetail(id) {
|
||||
.fc-dl__sentinel {
|
||||
display: flex; justify-content: center; padding: 1rem 0;
|
||||
}
|
||||
|
||||
.fc-dl__group { margin-bottom: 12px; }
|
||||
.fc-dl__group-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: rgb(var(--v-theme-on-surface) / 0.04);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.fc-dl__group-head:hover {
|
||||
background: rgb(var(--v-theme-on-surface) / 0.08);
|
||||
}
|
||||
.fc-dl__group-chev {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-dl__group-label {
|
||||
font-weight: 600;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
flex: 1;
|
||||
}
|
||||
.fc-dl__group-counts {
|
||||
display: flex; gap: 6px; align-items: center;
|
||||
}
|
||||
.fc-dl__group-body { margin-top: 4px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user