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:
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user