feat(dashboards): 1600px max-width, richer Downloads filters, needs-attention + sticky headers

Operator-flagged 2026-05-28: the Subscriptions/Downloads dashboards were
full-bleed and thin on filtering. Chosen via AskUserQuestion.

**Layout**
- SubscriptionsView capped at a centered max-width 1600px (covers all
  three subtabs) so rows aren't a mile wide on ultrawide monitors.
- Sticky control headers on both tabs (top: 48px, below the top nav,
  opaque bg) so filters/stat-chips stay reachable while scrolling a
  long list.

**Downloads filters (all four requested)**
- Stat chips are now clickable filters: click Queued/Running/Completed/
  Failed/Skipped to filter the list to that status; the active chip is
  outlined + elevated; re-click clears.
- Free-text search box over the loaded events (artist / platform /
  error substring).
- Artist filter: the filter popover's numeric "Source ID" field is
  replaced with an artist autocomplete (sources.autocompleteArtist →
  artist_id, which /api/downloads already supports). Pill shows the
  artist name.
- "Show no-change scans" toggle (default OFF): hides status=ok/skipped
  rows with 0 files (the scheduled scans that found nothing) so real
  downloads + failures stand out.

**Subscriptions**
- "Needs attention" quick-filter chip: one click to show only artists
  with sources that have errors OR have never been checked; chip shows
  the count and disables the status dropdown while active.

Frontend-only — backend filter params (status/artist_id/date) and the
/api/downloads endpoint already supported everything.
This commit is contained in:
2026-05-28 08:10:04 -04:00
parent 73520b7cc3
commit a459d21a65
5 changed files with 184 additions and 32 deletions
@@ -3,9 +3,11 @@
<v-chip
v-for="s in STAT_DEFS" :key="s.key"
:color="s.color"
variant="tonal"
:variant="activeStatus === s.key ? 'elevated' : 'tonal'"
:prepend-icon="s.icon"
size="default"
:class="{ 'fc-dl-stats__active': activeStatus === s.key }"
@click="$emit('select', activeStatus === s.key ? null : s.key)"
>
{{ s.label }}
<strong class="ms-1">{{ stats[s.key] ?? 0 }}</strong>
@@ -16,7 +18,11 @@
<script setup>
defineProps({
stats: { type: Object, required: true },
// The currently-active status filter key (pending|running|ok|error|
// skipped) or null. Highlights the matching chip.
activeStatus: { type: String, default: null },
})
defineEmits(['select'])
// status keys come straight from the backend ENUM
// (pending|running|ok|error|skipped); display order + icons are UI-only.
@@ -33,4 +39,9 @@ const STAT_DEFS = [
.fc-dl-stats {
display: flex; gap: 8px; flex-wrap: wrap;
}
.fc-dl-stats .v-chip { cursor: pointer; }
.fc-dl-stats__active {
outline: 2px solid rgb(var(--v-theme-on-surface) / 0.5);
outline-offset: 1px;
}
</style>
@@ -9,7 +9,7 @@
</v-chip>
</v-btn>
</template>
<v-card min-width="320" class="pa-3">
<v-card min-width="340" class="pa-3">
<v-select
v-model="local.status"
:items="STATUS_OPTIONS"
@@ -17,12 +17,17 @@
density="compact" variant="outlined" hide-details clearable
class="mb-2"
/>
<v-text-field
v-model.number="local.source_id"
label="Source ID"
<v-autocomplete
v-model="local.artist_id"
:items="artistItems"
:loading="artistLoading"
label="Artist"
item-title="name" item-value="id"
density="compact" variant="outlined" hide-details clearable
type="number" min="1"
no-filter
class="mb-2"
@update:search="onArtistSearch"
@update:model-value="onArtistPick"
/>
<v-text-field
v-model="local.from_date"
@@ -62,11 +67,15 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { useSourcesStore } from '../../stores/sources.js'
const props = defineProps({
modelValue: { type: Object, required: true },
})
const emit = defineEmits(['update:modelValue'])
const sourcesStore = useSourcesStore()
const STATUS_OPTIONS = [
{ title: 'Queued', value: 'pending' },
{ title: 'Running', value: 'running' },
@@ -78,18 +87,43 @@ const STATUS_LABEL = Object.fromEntries(STATUS_OPTIONS.map((o) => [o.value, o.ti
const open = ref(false)
const local = reactive({
status: props.modelValue.status ?? null,
source_id: props.modelValue.source_id ?? null,
from_date: props.modelValue.from_date ?? null,
to_date: props.modelValue.to_date ?? null,
status: props.modelValue.status ?? null,
artist_id: props.modelValue.artist_id ?? null,
artist_name: props.modelValue.artist_name ?? null,
from_date: props.modelValue.from_date ?? null,
to_date: props.modelValue.to_date ?? null,
})
watch(() => props.modelValue, (v) => Object.assign(local, v), { deep: true })
// Artist autocomplete for the filter (replaces the old numeric Source-ID
// field — operator-flagged 2026-05-28). Keeps the currently-selected
// artist in the items list so the chip label resolves even before a
// search runs.
const artistItems = ref([])
const artistLoading = ref(false)
let artistDebounce = null
function onArtistSearch(q) {
if (artistDebounce) clearTimeout(artistDebounce)
if (!q || !q.trim()) { return }
artistDebounce = setTimeout(async () => {
artistLoading.value = true
try {
artistItems.value = await sourcesStore.autocompleteArtist(q.trim(), 20)
} finally {
artistLoading.value = false
}
}, 250)
}
function onArtistPick(id) {
const hit = artistItems.value.find((a) => a.id === id)
local.artist_name = hit ? hit.name : null
}
const activePills = computed(() => {
const out = []
if (local.status) out.push({ key: 'status', label: `Status: ${STATUS_LABEL[local.status] || local.status}` })
if (local.source_id) out.push({ key: 'source_id', label: `Source #${local.source_id}` })
if (local.artist_id) out.push({ key: 'artist_id', label: `Artist: ${local.artist_name || `#${local.artist_id}`}` })
if (local.from_date) out.push({ key: 'from_date', label: `From ${local.from_date}` })
if (local.to_date) out.push({ key: 'to_date', label: `To ${local.to_date}` })
return out
@@ -102,13 +136,19 @@ function apply() {
}
function reset() {
local.status = null
local.source_id = null
local.artist_id = null
local.artist_name = null
local.from_date = null
local.to_date = null
emit('update:modelValue', { ...local })
}
function clearOne(key) {
local[key] = null
if (key === 'artist_id') {
local.artist_id = null
local.artist_name = null
} else {
local[key] = null
}
emit('update:modelValue', { ...local })
}
</script>
@@ -1,20 +1,41 @@
<template>
<div>
<div class="fc-dl__top">
<DownloadStatChips :stats="store.stats" />
<span v-if="liveActive" class="fc-dl__live" title="Auto-refreshing while downloads are active">
<span class="fc-dl__live-dot" />
live
</span>
<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>
<div class="fc-dl__sticky">
<div class="fc-dl__top">
<DownloadStatChips
:stats="store.stats"
:active-status="filterModel.status"
@select="onStatusChipSelect"
/>
<span v-if="liveActive" class="fc-dl__live" title="Auto-refreshing while downloads are active">
<span class="fc-dl__live-dot" />
live
</span>
<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" />
<div class="fc-dl__controls">
<v-text-field
v-model="search"
density="compact" variant="outlined" hide-details clearable
prepend-inner-icon="mdi-magnify"
placeholder="Search artist / platform / error"
style="max-width: 340px"
/>
<DownloadsFilterPopover v-model="filterModel" />
<v-switch
v-model="showNoChange"
label="Show no-change scans"
density="compact" hide-details color="accent"
class="fc-dl__nochange"
/>
</div>
</div>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
{{ String(store.error) }}
@@ -92,6 +113,17 @@ const route = useRoute()
const store = useDownloadsStore()
const filterModel = ref({ ...store.filter })
// Free-text search (client-side over the loaded events) + a toggle to
// hide "no-change" scheduled scans (status=ok/skipped with 0 files) so
// real downloads + failures stand out. Operator-flagged 2026-05-28.
const search = ref('')
const showNoChange = ref(false)
// Clicking a stat chip toggles a status filter (re-click clears it).
function onStatusChipSelect(statusKey) {
filterModel.value = { ...filterModel.value, status: statusKey }
}
// 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).
@@ -158,6 +190,21 @@ const filteredEvents = computed(() => {
const toTs = new Date(to).getTime() + 24 * 3600 * 1000 - 1
arr = arr.filter((e) => new Date(e.started_at).getTime() <= toTs)
}
// Hide no-change scheduled scans (succeeded/skipped, downloaded
// nothing) unless the operator opts to show them.
if (!showNoChange.value) {
arr = arr.filter(
(e) => !((e.status === 'ok' || e.status === 'skipped') && !(e.files_count > 0)),
)
}
const q = search.value?.trim().toLowerCase()
if (q) {
arr = arr.filter((e) =>
(e.artist_name || '').toLowerCase().includes(q)
|| (e.platform || '').toLowerCase().includes(q)
|| (e.error || '').toLowerCase().includes(q),
)
}
return arr
})
@@ -207,6 +254,7 @@ watch(filterModel, async (m) => {
await store.applyFilter({
status: m.status,
source_id: m.source_id || null,
artist_id: m.artist_id || null,
from_date: m.from_date,
to_date: m.to_date,
})
@@ -219,12 +267,27 @@ async function openDetail(id) {
</script>
<style scoped>
/* Sticky control header: stat chips + search + filter + toggle stay
pinned below the top nav while the event feed scrolls. Opaque
background so rows don't bleed through. */
.fc-dl__sticky {
position: sticky;
top: 48px;
z-index: 3;
background: rgb(var(--v-theme-background));
padding: 8px 0 10px;
margin-bottom: 4px;
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.12);
}
.fc-dl__top {
display: flex; gap: 8px; align-items: center;
margin-bottom: 12px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.fc-dl__filter { margin-bottom: 12px; }
.fc-dl__controls {
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
}
.fc-dl__nochange { flex: 0 0 auto; }
.fc-dl__live {
display: inline-flex; align-items: center; gap: 5px;
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
@@ -7,10 +7,22 @@
<v-btn variant="outlined" prepend-icon="mdi-account-plus" @click="showArtistDialog = true">
New artist
</v-btn>
<v-chip
:color="needsAttention ? 'error' : undefined"
:variant="needsAttention ? 'flat' : 'outlined'"
prepend-icon="mdi-alert-circle-outline"
@click="needsAttention = !needsAttention"
>
Needs attention
<v-chip v-if="attentionCount" size="x-small" class="ms-2" variant="tonal">
{{ attentionCount }}
</v-chip>
</v-chip>
<v-spacer />
<v-select
v-model="statusFilter"
:items="STATUS_OPTIONS"
:disabled="needsAttention"
density="compact" variant="outlined" hide-details
style="max-width: 180px"
/>
@@ -218,6 +230,7 @@ const importStore = useImportStore()
const search = ref('')
const statusFilter = ref('all')
const needsAttention = ref(false)
const expanded = ref([])
const selected = ref([])
const showSourceDialog = ref(false)
@@ -277,12 +290,23 @@ const groups = computed(() => {
})
})
// A group "needs attention" if any of its sources has accumulated
// failures OR has never been checked — the ones worth acting on.
function groupNeedsAttention(g) {
return groupMatchesStatus(g, 'errors') || groupMatchesStatus(g, 'stale')
}
const attentionCount = computed(
() => groups.value.filter(groupNeedsAttention).length,
)
const filteredGroups = computed(() => {
let arr = groups.value
if (artistFilter.value != null) {
arr = arr.filter((g) => g.artist.id === artistFilter.value)
}
if (statusFilter.value !== 'all') {
if (needsAttention.value) {
arr = arr.filter(groupNeedsAttention)
} else if (statusFilter.value !== 'all') {
arr = arr.filter((g) => groupMatchesStatus(g, statusFilter.value))
}
const q = search.value?.trim().toLowerCase()
@@ -478,9 +502,17 @@ async function bulkDelete() {
<style scoped>
.fc-subs__bar {
display: flex; gap: 0.75rem; align-items: center;
padding-bottom: 1rem;
flex-wrap: wrap;
/* Sticky below the top nav so the filter/search controls stay
reachable while scrolling a long subscription list. Opaque bg so
table rows don't bleed through. Operator-flagged 2026-05-28. */
position: sticky;
top: 48px;
z-index: 3;
background: rgb(var(--v-theme-background));
padding: 8px 0 1rem;
}
.fc-subs__bar .v-chip { cursor: pointer; }
.fc-subs__loading, .fc-subs__empty {
display: flex; justify-content: center; padding: 2rem;
color: rgb(var(--v-theme-on-surface-variant));
+7 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="pt-2 pb-6">
<v-container fluid class="pt-2 pb-6 fc-subs-shell">
<v-tabs
v-model="tab"
align-tabs="start"
@@ -65,6 +65,12 @@ watch(() => route.query.tab, (q) => {
</script>
<style scoped>
/* Cap the dashboards at a centered 1600px so rows aren't a mile wide on
wide/ultrawide monitors (operator-flagged 2026-05-28). */
.fc-subs-shell {
max-width: 1600px;
margin-inline: auto;
}
.fc-subs-tabs {
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
}