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:
2026-05-27 22:18:02 -04:00
parent df6d89cb59
commit b1b129ce9f
2 changed files with 292 additions and 42 deletions
@@ -1,47 +1,118 @@
<template>
<div class="fc-dl-row" @click="$emit('open', event.id)">
<v-icon :icon="statusIcon" :color="statusColor" size="small" />
<div
class="fc-dl-row"
:class="[`fc-dl-row--${event.status || 'unknown'}`]"
@click="$emit('open', event.id)"
>
<!-- Colored left edge marks the run's status; matches the row's
status-chip color but reads at a glance without needing to
parse the chip text. -->
<div class="fc-dl-row__bar" />
<v-chip
:color="statusColor"
size="small"
variant="tonal"
:prepend-icon="statusIcon"
class="fc-dl-row__status"
>{{ statusLabel }}</v-chip>
<RouterLink
v-if="event.artist_slug"
:to="`/artist/${event.artist_slug}`"
class="fc-dl-row__artist"
@click.stop
>{{ event.artist_name }}</RouterLink>
<span v-else class="fc-dl-row__artist"></span>
<v-chip size="x-small" variant="tonal">{{ event.platform || '—' }}</v-chip>
<span class="fc-dl-row__time">{{ fmtTime(event.started_at) }}</span>
<span class="fc-dl-row__files">{{ event.files_count }} files</span>
<span class="fc-dl-row__duration">{{ fmtDuration(event.summary?.duration_seconds) }}</span>
<span v-if="event.error" class="fc-dl-row__error">{{ event.error }}</span>
<span v-else class="fc-dl-row__artist fc-dl-row__artist--missing"></span>
<PlatformChip
v-if="event.platform"
:platform="event.platform"
size="x-small"
class="fc-dl-row__platform"
/>
<span v-else class="fc-dl-row__platform-missing"></span>
<span class="fc-dl-row__time" :title="event.started_at">
{{ fmtTime(event.started_at) }}
</span>
<v-chip
v-if="event.files_count > 0"
size="x-small" variant="tonal" color="info"
prepend-icon="mdi-image-multiple"
class="fc-dl-row__files"
>{{ event.files_count }}</v-chip>
<span v-else class="fc-dl-row__no-files" aria-label="no new files">·</span>
<span class="fc-dl-row__duration">
{{ fmtDuration(event.summary?.duration_seconds) }}
</span>
<v-chip
v-if="event.error"
color="error" size="x-small" variant="tonal"
prepend-icon="mdi-alert-octagon"
class="fc-dl-row__error"
:title="event.error"
>{{ truncateError(event.error) }}</v-chip>
<span v-else class="fc-dl-row__error-spacer" />
<div class="fc-dl-row__actions" @click.stop>
<v-btn
v-if="event.status === 'error' && event.source_id"
icon size="x-small" variant="text" color="warning"
:loading="retrying"
@click.stop="onRetry"
>
<v-icon size="small">mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">Retry source check</v-tooltip>
</v-btn>
<v-btn icon size="x-small" variant="text" @click.stop="$emit('open', event.id)">
<v-icon size="small">mdi-information-outline</v-icon>
<v-tooltip activator="parent" location="top">Details</v-tooltip>
</v-btn>
<v-btn
v-if="event.artist_slug"
icon size="x-small" variant="text"
:to="`/artist/${event.artist_slug}`"
@click.stop
>
<v-icon size="small">mdi-account-circle</v-icon>
<v-tooltip activator="parent" location="top">Open artist</v-tooltip>
</v-btn>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import PlatformChip from '../subscriptions/PlatformChip.vue'
import { useSourcesStore } from '../../stores/sources.js'
const props = defineProps({ event: { type: Object, required: true } })
defineEmits(['open'])
const statusIcon = computed(() => ({
ok: 'mdi-check-circle',
error: 'mdi-alert-circle',
running: 'mdi-progress-clock',
pending: 'mdi-clock-outline',
skipped: 'mdi-minus-circle',
}[props.event.status] || 'mdi-help-circle'))
const sourcesStore = useSourcesStore()
const retrying = ref(false)
const statusColor = computed(() => ({
ok: 'success',
error: 'error',
running: 'info',
pending: 'secondary',
skipped: 'warning',
}[props.event.status] || undefined))
const _STATUS = {
ok: { color: 'success', icon: 'mdi-check-circle', label: 'Completed' },
error: { color: 'error', icon: 'mdi-alert-circle', label: 'Failed' },
running: { color: 'info', icon: 'mdi-progress-clock', label: 'Running' },
pending: { color: 'grey', icon: 'mdi-clock-outline', label: 'Queued' },
skipped: { color: 'warning', icon: 'mdi-skip-next', label: 'Skipped' },
}
const statusColor = computed(() => _STATUS[props.event.status]?.color || 'grey')
const statusIcon = computed(() => _STATUS[props.event.status]?.icon || 'mdi-help-circle')
const statusLabel = computed(() => _STATUS[props.event.status]?.label || props.event.status)
function fmtTime(iso) {
if (!iso) return '—'
return iso.slice(0, 19).replace('T', ' ')
// 2026-05-27 23:36 — second granularity is in the row's title attr
return iso.slice(0, 16).replace('T', ' ')
}
function fmtDuration(sec) {
if (sec == null) return '—'
@@ -49,34 +120,107 @@ function fmtDuration(sec) {
const m = Math.floor(sec / 60), s = Math.floor(sec % 60)
return `${m}m ${s}s`
}
function truncateError(msg) {
const s = String(msg || '')
if (s.length <= 60) return s
return s.slice(0, 57) + '…'
}
async function onRetry() {
if (!props.event.source_id) return
retrying.value = true
try {
await sourcesStore.checkNow(props.event.source_id)
globalThis.window?.__fcToast?.({
text: `Source check re-queued`, type: 'success',
})
} catch (e) {
const isInFlight = !!e?.body?.download_event_id
globalThis.window?.__fcToast?.({
text: isInFlight ? 'Already running' : `Retry failed: ${e?.detail || e?.message || e}`,
type: isInFlight ? 'info' : 'error',
})
} finally {
retrying.value = false
}
}
</script>
<style scoped>
.fc-dl-row {
position: relative;
display: grid;
grid-template-columns: 24px 1fr 96px 160px 80px 80px 1fr;
gap: 0.75rem;
grid-template-columns:
/* bar */ 4px
/* status */ 120px
/* artist */ minmax(120px, 1.2fr)
/* plat */ 140px
/* time */ 140px
/* files */ 60px
/* dur */ 70px
/* error */ minmax(0, 1.5fr)
/* actions*/ 120px;
gap: 0.6rem;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
padding: 0.55rem 0.75rem 0.55rem 0;
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.15);
cursor: pointer;
transition: background 0.12s ease;
}
.fc-dl-row:hover { background: rgb(var(--v-theme-surface) / 0.5); }
.fc-dl-row:hover {
background: rgb(var(--v-theme-on-surface) / 0.04);
}
.fc-dl-row__bar {
width: 4px;
align-self: stretch;
border-radius: 0 2px 2px 0;
}
.fc-dl-row--ok .fc-dl-row__bar { background: rgb(var(--v-theme-success)); }
.fc-dl-row--error .fc-dl-row__bar { background: rgb(var(--v-theme-error)); }
.fc-dl-row--running .fc-dl-row__bar { background: rgb(var(--v-theme-info)); }
.fc-dl-row--skipped .fc-dl-row__bar { background: rgb(var(--v-theme-warning)); }
.fc-dl-row--pending .fc-dl-row__bar { background: rgb(var(--v-theme-on-surface-variant) / 0.4); }
.fc-dl-row__status { justify-self: start; }
.fc-dl-row__artist {
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-weight: 500;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fc-dl-row__artist--missing,
.fc-dl-row__platform-missing,
.fc-dl-row__no-files {
color: rgb(var(--v-theme-on-surface-variant));
opacity: 0.5;
}
.fc-dl-row__artist:hover { color: rgb(var(--v-theme-accent)); }
.fc-dl-row__time, .fc-dl-row__files, .fc-dl-row__duration {
.fc-dl-row__platform { justify-self: start; }
.fc-dl-row__time,
.fc-dl-row__duration {
color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.fc-dl-row__error {
color: rgb(var(--v-theme-error));
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fc-dl-row__no-files {
text-align: center;
font-size: 1.1rem;
}
.fc-dl-row__error {
justify-self: start;
max-width: 100%;
}
.fc-dl-row__error :deep(.v-chip__content) {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fc-dl-row__error-spacer { /* keeps the grid column reserved */ }
.fc-dl-row__actions {
display: flex; gap: 2px;
justify-self: end;
opacity: 0.5;
transition: opacity 0.12s ease;
}
.fc-dl-row:hover .fc-dl-row__actions { opacity: 1; }
</style>
@@ -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>