refactor(ui): CardHeading primitive for icon+title card/dialog headings (DRY pattern sweep)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m10s

The icon+title v-card-title heading (d-flex align-center + gap + <v-icon size=small> +
<span>) was hand-rolled identically in 13 cards/dialogs (15 heading instances).
Consolidate to <CardHeading icon title> (components/common) with an iconColor
prop (error headings) and a default slot for trailing content (spacer+actions,
inline status chip). Adopted everywhere the pattern appears — all-or-nothing per
the hardened DRY process.

Over-DRY guard: plain text-only <v-card-title> one-liners are NOT this pattern
and stay; DownloadDetailModal leads with a status CHIP (not an icon), a different
concept, left alone. §8b: the only remaining d-flex align-center v-card-title is
that intentional variant. Catalog updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:22:01 -04:00
parent 4854d74c5a
commit 9deebfa133
14 changed files with 66 additions and 59 deletions
@@ -1,9 +1,6 @@
<template>
<v-card class="fc-backup-card">
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-database-export" size="small" />
<span>Backups</span>
</v-card-title>
<CardHeading icon="mdi-database-export" title="Backups" />
<v-card-text>
<p class="fc-muted text-body-2 mb-4">
pg_dump for the database (fast, nightly-schedulable);
@@ -101,6 +98,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useBackupStore } from '../../stores/backup.js'
import BackupConfirmModal from '../modal/DestructiveConfirmModal.vue'
import CardHeading from '../common/CardHeading.vue'
import BackupRunsTable from './BackupRunsTable.vue'
const store = useBackupStore()
@@ -1,12 +1,10 @@
<template>
<v-card class="fc-ext-card">
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-puzzle" size="small" />
<span>Browser extension</span>
<CardHeading icon="mdi-puzzle" title="Browser extension">
<span v-if="manifest?.installed" class="text-caption fc-muted">
· Firefox · v{{ manifest.version }}
</span>
</v-card-title>
</CardHeading>
<v-card-text>
<p class="fc-muted text-body-2">
@@ -112,6 +110,7 @@ import { toast } from '../../utils/toast.js'
import { computed, onMounted, ref } from 'vue'
import { useApi } from '../../composables/useApi.js'
import { copyText } from '../../utils/clipboard.js'
import CardHeading from '../common/CardHeading.vue'
const api = useApi()
@@ -1,7 +1,6 @@
<template>
<v-card>
<v-card-title class="d-flex align-center" style="gap: 12px;">
<span>Recent import tasks</span>
<CardHeading title="Recent import tasks">
<v-spacer />
<v-select
v-model="statusFilter" :items="statusOptions" density="compact"
@@ -29,7 +28,7 @@
>
Clear completed
</v-btn>
</v-card-title>
</CardHeading>
<v-data-table-virtual
:headers="headers" :items="store.tasks" :loading="store.tasksLoading"
height="480" density="compact" no-data-text="No tasks yet trigger a scan above."
@@ -129,6 +128,7 @@ import { toast } from '../../utils/toast.js'
import { computed, ref } from 'vue'
import { useImportStore } from '../../stores/import.js'
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
import CardHeading from '../common/CardHeading.vue'
const store = useImportStore()
const statusFilter = ref(null)
@@ -1,9 +1,6 @@
<template>
<v-card class="fc-post-maint">
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-post-outline" size="small" />
<span>Post maintenance</span>
</v-card-title>
<CardHeading icon="mdi-post-outline" title="Post maintenance" />
<v-card-text>
<p class="fc-muted text-body-2 mb-4">
Remove <strong>bare posts</strong> post rows with no images (neither
@@ -61,6 +58,7 @@ import { ref } from 'vue'
import { useAdminStore } from '../../stores/admin.js'
import SampleNameGrid from '../common/SampleNameGrid.vue'
import CardHeading from '../common/CardHeading.vue'
const store = useAdminStore()
const preview = ref(null)
@@ -1,8 +1,6 @@
<template>
<v-card class="fc-activity-summary">
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-pulse" size="small" />
<span>System activity (last min)</span>
<CardHeading icon="mdi-pulse" title="System activity (last min)">
<v-spacer />
<v-btn
variant="text" size="small" rounded="pill"
@@ -11,7 +9,7 @@
View activity
<v-icon end size="small">mdi-arrow-right</v-icon>
</v-btn>
</v-card-title>
</CardHeading>
<v-card-text>
<v-alert
@@ -36,6 +34,7 @@ import { onMounted, onUnmounted } from 'vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js'
import QueuesTable from './QueuesTable.vue'
import CardHeading from '../common/CardHeading.vue'
defineEmits(['open-activity'])
@@ -2,14 +2,12 @@
<div class="fc-activity">
<!-- Queues + workers pane -->
<v-card class="mb-4">
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-pulse" size="small" />
<span>Queues + workers</span>
<CardHeading icon="mdi-pulse" title="Queues + workers">
<v-spacer />
<span class="text-caption fc-muted">
updated {{ formatRelative(store.queues?.fetched_at) }}
</span>
</v-card-title>
</CardHeading>
<v-card-text>
<QueuesTable
:queues="store.queues"
@@ -21,14 +19,12 @@
<!-- Recent failures pane -->
<v-card class="mb-4">
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-alert-circle-outline" size="small" />
<span>Recent failures (last 24h)</span>
<CardHeading icon="mdi-alert-circle-outline" title="Recent failures (last 24h)">
<v-spacer />
<span class="text-caption fc-muted">
{{ failureCount }} failures
</span>
</v-card-title>
</CardHeading>
<v-card-text>
<div v-if="errorTypes.length" class="mb-3 fc-pills">
<v-chip
@@ -79,9 +75,7 @@
<!-- All activity pane -->
<v-card>
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-format-list-bulleted" size="small" />
<span>All recent activity</span>
<CardHeading icon="mdi-format-list-bulleted" title="All recent activity">
<v-spacer />
<v-select
v-model="filterQueue"
@@ -102,7 +96,7 @@
<v-icon start size="small">mdi-refresh</v-icon>
Refresh
</v-btn>
</v-card-title>
</CardHeading>
<v-card-text>
<v-table density="compact">
<thead>
@@ -157,6 +151,7 @@ import { useSystemActivityStore } from '../../stores/systemActivity.js'
import { formatRelative as fmtRelative } from '../../utils/date.js'
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
import QueuesTable from './QueuesTable.vue'
import CardHeading from '../common/CardHeading.vue'
// Click-to-open modal for full error text. Replaces the unusable
// :title="..." tooltip (operator-flagged 2026-05-26: SQLAlchemy
@@ -1,9 +1,6 @@
<template>
<v-card class="fc-tag-maint">
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-tag-remove" size="small" />
<span>Tag maintenance</span>
</v-card-title>
<CardHeading icon="mdi-tag-remove" title="Tag maintenance" />
<v-card-text>
<p class="fc-muted text-body-2 mb-4">
Remove tags with zero image associations and zero series-page
@@ -196,6 +193,7 @@ import { ref } from 'vue'
import { useAdminStore } from '../../stores/admin.js'
import SampleNameGrid from '../common/SampleNameGrid.vue'
import CardHeading from '../common/CardHeading.vue'
const store = useAdminStore()
const preview = ref(null)