feat(fc2a): add SettingsView shell with Overview tab (SystemStatsCards)

Overview tab shows total images/tags/storage/pending/failed using
Fraunces for the big numbers. Inline alert nudges the operator to the
Import tab when pending tasks exist. Polls /api/system/stats every 5s
while the tab is visible (pauses when the document is hidden).

Import tab references three placeholders (TriggerPanel/FiltersForm/
TaskList); real implementations land in Task 15.

Router replaces the placeholder view for /settings with SettingsView.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:30:27 -04:00
parent af9f42ef68
commit 3bcff142f5
6 changed files with 154 additions and 1 deletions
@@ -0,0 +1 @@
<template><div class="text-caption">ImportFiltersForm implemented in Task 15</div></template>
@@ -0,0 +1 @@
<template><div class="text-caption">ImportTaskList implemented in Task 15</div></template>
@@ -0,0 +1 @@
<template><div class="text-caption">ImportTriggerPanel implemented in Task 15</div></template>
@@ -0,0 +1,63 @@
<template>
<v-row dense>
<v-col v-for="card in cards" :key="card.label" cols="12" sm="6" md="4" lg="2">
<v-card class="fc-stat">
<v-card-text>
<div class="fc-stat__label text-caption">{{ card.label }}</div>
<div class="fc-stat__value">{{ card.value }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
stats: { type: Object, default: null }
})
function formatBytes(bytes) {
if (!bytes) return '0 B'
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let i = 0
let value = bytes
while (value >= 1024 && i < units.length - 1) { value /= 1024; i++ }
return `${value.toFixed(value >= 100 || i === 0 ? 0 : 1)} ${units[i]}`
}
const cards = computed(() => {
const s = props.stats
if (!s) return [
{ label: 'Total images', value: '—' },
{ label: 'Total tags', value: '—' },
{ label: 'Storage used', value: '—' },
{ label: 'Pending tasks', value: '—' },
{ label: 'Failed tasks', value: '—' }
]
return [
{ label: 'Total images', value: s.total_images.toLocaleString() },
{ label: 'Total tags', value: s.total_tags.toLocaleString() },
{ label: 'Storage used', value: formatBytes(s.storage_bytes) },
{ label: 'Pending', value: (s.tasks.pending + s.tasks.queued).toLocaleString() },
{ label: 'Failed', value: s.tasks.failed.toLocaleString() }
]
})
</script>
<style scoped>
.fc-stat__label {
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
}
.fc-stat__value {
font-family: 'Fraunces', Georgia, serif;
font-size: 28px;
font-weight: 500;
color: rgb(var(--v-theme-on-surface));
margin-top: 4px;
}
</style>
+2 -1
View File
@@ -1,12 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
import PlaceholderView from './views/PlaceholderView.vue'
import SettingsView from './views/SettingsView.vue'
const routes = [
// FC-2: image backbone
{ path: '/', name: 'gallery', component: PlaceholderView, meta: { title: 'Gallery' } },
{ path: '/showcase', name: 'showcase', component: PlaceholderView, meta: { title: 'Showcase' } },
{ path: '/tags', name: 'tags', component: PlaceholderView, meta: { title: 'Tags' } },
{ path: '/settings', name: 'settings', component: PlaceholderView, meta: { title: 'Settings' } },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
// FC-3: subscription backbone
{ path: '/subscriptions', name: 'subscriptions', component: PlaceholderView, meta: { title: 'Subscriptions' } },
+86
View File
@@ -0,0 +1,86 @@
<template>
<v-container class="py-6">
<h1 class="fc-h1 mb-6">Settings</h1>
<v-tabs v-model="tab" color="accent" class="mb-4">
<v-tab value="overview">Overview</v-tab>
<v-tab value="import">Import</v-tab>
</v-tabs>
<v-window v-model="tab">
<v-window-item value="overview">
<SystemStatsCards :stats="system.stats" />
<v-alert
v-if="system.stats && (system.stats.tasks.pending + system.stats.tasks.queued) > 0"
type="info" variant="tonal" class="mt-4" closable
>
{{ system.stats.tasks.pending + system.stats.tasks.queued }} import task(s) pending.
<v-btn variant="text" size="small" @click="tab = 'import'">Go to Import tab</v-btn>
</v-alert>
</v-window-item>
<v-window-item value="import">
<ImportTriggerPanel />
<v-divider class="my-6" />
<ImportFiltersForm />
<v-divider class="my-6" />
<ImportTaskList />
</v-window-item>
</v-window>
</v-container>
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useSystemStore } from '../stores/system.js'
import { useImportStore } from '../stores/import.js'
import SystemStatsCards from '../components/settings/SystemStatsCards.vue'
import ImportTriggerPanel from '../components/settings/ImportTriggerPanel.vue'
import ImportFiltersForm from '../components/settings/ImportFiltersForm.vue'
import ImportTaskList from '../components/settings/ImportTaskList.vue'
const tab = ref('overview')
const system = useSystemStore()
const importStore = useImportStore()
let pollId = null
function startPolling() {
if (pollId) return
system.refreshStats()
pollId = setInterval(() => {
if (!document.hidden) {
system.refreshStats()
if (tab.value === 'import') importStore.refreshStatus()
}
}, 5000)
}
function stopPolling() {
if (pollId) { clearInterval(pollId); pollId = null }
}
onMounted(() => {
startPolling()
importStore.loadSettings()
importStore.refreshStatus()
importStore.loadTasks(true)
})
onUnmounted(stopPolling)
watch(tab, (t) => {
if (t === 'import') {
importStore.refreshStatus()
importStore.loadTasks(true)
}
})
</script>
<style scoped>
.fc-h1 {
font-family: 'Fraunces', Georgia, serif;
font-size: 32px;
font-weight: 500;
color: rgb(var(--v-theme-on-surface));
}
</style>