feat(fc3c): /downloads view + DownloadEventRow + DownloadDetailModal + FilterPills
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<v-dialog :model-value="!!event" max-width="900" @update:model-value="onClose">
|
||||
<v-card v-if="event">
|
||||
<v-card-title class="d-flex align-center" style="gap: 0.5rem">
|
||||
<v-chip size="small" :color="statusColor">{{ event.status }}</v-chip>
|
||||
<span>{{ event.platform || '—' }} · {{ event.artist_name || '—' }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-caption" style="opacity: 0.75">
|
||||
{{ event.started_at }} → {{ event.finished_at || '(running)' }}
|
||||
({{ fmtDuration(event.summary?.duration_seconds) }})
|
||||
</p>
|
||||
|
||||
<h3 class="text-subtitle-2 mt-3">Run stats</h3>
|
||||
<div class="fc-dl-grid">
|
||||
<div><span class="fc-dl-grid__label">Downloaded</span><span>{{ stats.downloaded_count ?? 0 }}</span></div>
|
||||
<div><span class="fc-dl-grid__label">Skipped</span><span>{{ stats.skipped_count ?? 0 }}</span></div>
|
||||
<div><span class="fc-dl-grid__label">Per-item failures</span><span>{{ stats.per_item_failures ?? 0 }}</span></div>
|
||||
<div><span class="fc-dl-grid__label">Warnings</span><span>{{ stats.warning_count ?? 0 }}</span></div>
|
||||
<div><span class="fc-dl-grid__label">Tier-gated</span><span>{{ stats.tier_gated_count ?? 0 }}</span></div>
|
||||
<div><span class="fc-dl-grid__label">Quarantined</span><span>{{ stats.quarantined_count ?? 0 }}</span></div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-subtitle-2 mt-4">Import summary</h3>
|
||||
<div class="fc-dl-grid">
|
||||
<div><span class="fc-dl-grid__label">Attached</span><span>{{ summary.attached ?? 0 }}</span></div>
|
||||
<div><span class="fc-dl-grid__label">Skipped (dedup)</span><span>{{ summary.skipped ?? 0 }}</span></div>
|
||||
<div><span class="fc-dl-grid__label">Errors</span><span>{{ summary.errors ?? 0 }}</span></div>
|
||||
</div>
|
||||
|
||||
<template v-if="quarantinedPaths.length">
|
||||
<h3 class="text-subtitle-2 mt-4">Quarantined paths</h3>
|
||||
<ul class="fc-dl-quar">
|
||||
<li v-for="p in quarantinedPaths" :key="p"><code>{{ p }}</code></li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<template v-if="errorsWarnings">
|
||||
<h3 class="text-subtitle-2 mt-4">Errors & warnings</h3>
|
||||
<pre class="fc-dl-pre">{{ errorsWarnings }}</pre>
|
||||
</template>
|
||||
|
||||
<v-expansion-panels class="mt-4">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>Raw stdout</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<pre class="fc-dl-pre">{{ event.metadata?.stdout || '(empty)' }}</pre>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>Raw stderr</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<pre class="fc-dl-pre">{{ event.metadata?.stderr || '(empty)' }}</pre>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="onClose(false)">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({ event: { type: Object, default: null } })
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const stats = computed(() => props.event?.metadata?.run_stats || {})
|
||||
const summary = computed(() => props.event?.metadata?.import_summary || {})
|
||||
const quarantinedPaths = computed(() => props.event?.metadata?.quarantined_paths || [])
|
||||
const errorsWarnings = computed(() => props.event?.metadata?.stderr_errors_warnings || '')
|
||||
|
||||
const statusColor = computed(() => ({
|
||||
ok: 'success', error: 'error', running: 'info',
|
||||
pending: 'secondary', skipped: 'warning',
|
||||
}[props.event?.status] || undefined))
|
||||
|
||||
function fmtDuration(sec) {
|
||||
if (sec == null) return '—'
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`
|
||||
const m = Math.floor(sec / 60), s = Math.floor(sec % 60)
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-dl-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.5rem 1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fc-dl-grid > div { display: flex; justify-content: space-between; }
|
||||
.fc-dl-grid__label {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-dl-pre {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.fc-dl-quar { padding-left: 1.5rem; font-size: 0.85rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="fc-dl-row" @click="$emit('open', event.id)">
|
||||
<v-icon :icon="statusIcon" :color="statusColor" size="small" />
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
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 statusColor = computed(() => ({
|
||||
ok: 'success',
|
||||
error: 'error',
|
||||
running: 'info',
|
||||
pending: 'secondary',
|
||||
skipped: 'warning',
|
||||
}[props.event.status] || undefined))
|
||||
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '—'
|
||||
return iso.slice(0, 19).replace('T', ' ')
|
||||
}
|
||||
function fmtDuration(sec) {
|
||||
if (sec == null) return '—'
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`
|
||||
const m = Math.floor(sec / 60), s = Math.floor(sec % 60)
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-dl-row {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr 96px 160px 80px 80px 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
|
||||
cursor: pointer;
|
||||
}
|
||||
.fc-dl-row:hover { background: rgb(var(--v-theme-surface) / 0.5); }
|
||||
.fc-dl-row__artist {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
text-decoration: none;
|
||||
}
|
||||
.fc-dl-row__artist:hover { color: rgb(var(--v-theme-accent)); }
|
||||
.fc-dl-row__time, .fc-dl-row__files, .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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<v-chip-group v-model="local" mandatory @update:model-value="onChange">
|
||||
<v-chip value="all">All</v-chip>
|
||||
<v-chip value="running">Running</v-chip>
|
||||
<v-chip value="error">Failed</v-chip>
|
||||
<v-chip value="quarantined">Quarantined</v-chip>
|
||||
</v-chip-group>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({ modelValue: { type: String, default: 'all' } })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const local = ref(props.modelValue)
|
||||
watch(() => props.modelValue, (v) => { local.value = v })
|
||||
|
||||
function onChange(v) {
|
||||
if (v !== props.modelValue) emit('update:modelValue', v)
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router'
|
||||
import PlaceholderView from './views/PlaceholderView.vue'
|
||||
import SettingsView from './views/SettingsView.vue'
|
||||
import GalleryView from './views/GalleryView.vue'
|
||||
import ShowcaseView from './views/ShowcaseView.vue'
|
||||
@@ -9,6 +8,7 @@ import SeriesManageView from './views/SeriesManageView.vue'
|
||||
import SeriesReaderView from './views/SeriesReaderView.vue'
|
||||
import SubscriptionsView from './views/SubscriptionsView.vue'
|
||||
import CredentialsView from './views/CredentialsView.vue'
|
||||
import DownloadsView from './views/DownloadsView.vue'
|
||||
|
||||
// The application's front door. `/` redirects here. Changing the front door
|
||||
// is a one-line edit (e.g. '/gallery' or '/tags').
|
||||
@@ -32,8 +32,8 @@ const routes = [
|
||||
|
||||
// FC-3: subscription backbone
|
||||
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions' } },
|
||||
{ path: '/credentials', name: 'credentials', component: CredentialsView, meta: { title: 'Credentials' } },
|
||||
{ path: '/downloads', name: 'downloads', component: PlaceholderView, meta: { title: 'Downloads' } }
|
||||
{ path: '/credentials', name: 'credentials', component: CredentialsView, meta: { title: 'Credentials' } },
|
||||
{ path: '/downloads', name: 'downloads', component: DownloadsView, meta: { title: 'Downloads' } }
|
||||
]
|
||||
|
||||
// Browser uses HTML5 history; non-browser (Vitest/SSR) falls back to memory
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<FilterPills v-model="pill" />
|
||||
|
||||
<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 yet. Trigger a check from /subscriptions.</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<DownloadEventRow
|
||||
v-for="e in store.events" :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()"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useDownloadsStore } from '../stores/downloads.js'
|
||||
import FilterPills from '../components/downloads/FilterPills.vue'
|
||||
import DownloadEventRow from '../components/downloads/DownloadEventRow.vue'
|
||||
import DownloadDetailModal from '../components/downloads/DownloadDetailModal.vue'
|
||||
|
||||
const store = useDownloadsStore()
|
||||
const pill = ref('all')
|
||||
|
||||
onMounted(() => store.loadFirst())
|
||||
|
||||
watch(pill, async (v) => {
|
||||
// 'quarantined' has no API filter yet — defer until the API grows
|
||||
// (a metadata.run_stats.quarantined_count > 0 filter, FC-3d territory).
|
||||
// For now route it the same as 'all' but keep the chip for discoverability.
|
||||
const statusMap = { all: null, running: 'running', error: 'error', quarantined: null }
|
||||
await store.applyFilter({ status: statusMap[v] })
|
||||
})
|
||||
|
||||
async function openDetail(id) {
|
||||
await store.loadOne(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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