feat(settings): tidy Cleanup tab into sectioned compact tiles (pass 1)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m14s

The Cleanup + Maintenance sections had ~17 full-width stacked cards with long
descriptions — a hunt to scan. Operator wants compact, sectioned, scannable tiles
(2026-06-18: keep both tabs, group inside, compact tiles in a grid).

New common/MaintenanceTile.vue: a compact expandable tile (icon + short title +
one-line blurb collapsed; click the header to expand the full controls/preview/
result inline; keyboard-accessible button + focus ring;  tints the
icon,  keeps a running task expanded).

Cleanup tab (this pass) restructured into 3 sections — Import-filter audits
(Min dimensions, Transparency, Single-color) / Duplicates & posts (Bare posts,
Duplicate posts, Deduplicate videos, Gated-post previews) / Tags (Unused, Legacy,
Reset content tagging, Standardize casing) — each a responsive grid of tiles.
PostMaintenanceCard split into 2 tiles, TagMaintenanceCard into 4. Moved
VideoDedupCard + GatedPurgeCard from the Maintenance tab here (both are
destructive content cleanup). All card logic unchanged — only the chrome.

Maintenance tab tiling is pass 2 (TODO noted in MaintenancePanel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 23:48:18 -04:00
parent eff64275fc
commit 3b435dc0ba
10 changed files with 697 additions and 533 deletions
@@ -1,49 +1,50 @@
<template>
<v-card class="fc-clean-card">
<CardHeading icon="mdi-image-size-select-small" title="Minimum dimensions" />
<v-card-text>
<p class="fc-muted text-body-2 mb-3">
Find and delete images smaller than the threshold. Mirrors the
import-time <code>min_width</code> / <code>min_height</code>
filter, applied retroactively to the existing library.
</p>
<MaintenanceTile
icon="mdi-image-size-select-small"
title="Minimum dimensions"
blurb="Delete images below the import min-width / min-height threshold."
destructive
>
<p class="fc-muted text-body-2 mb-3">
Mirrors the import-time <code>min_width</code> / <code>min_height</code>
filter, applied retroactively to the existing library.
</p>
<v-row dense>
<v-col cols="6">
<v-text-field
v-model.number="minW" label="Min width (px)" type="number"
min="0" density="compact" hide-details
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="minH" label="Min height (px)" type="number"
min="0" density="compact" hide-details
/>
</v-col>
</v-row>
<div class="d-flex align-center mt-3" style="gap: 10px;">
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="busy"
@click="onPreview"
>Preview</v-btn>
<span v-if="preview" class="text-body-2">
<strong>{{ preview.count }}</strong> image(s) would be deleted.
</span>
</div>
<v-row dense>
<v-col cols="6">
<v-text-field
v-model.number="minW" label="Min width (px)" type="number"
min="0" density="compact" hide-details
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="minH" label="Min height (px)" type="number"
min="0" density="compact" hide-details
/>
</v-col>
</v-row>
<div class="d-flex align-center mt-3" style="gap: 10px;">
<v-btn
v-if="preview && preview.count > 0"
class="mt-3"
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete"
@click="onDeleteClick"
>Delete {{ preview.count }} matching...</v-btn>
</v-card-text>
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="busy"
@click="onPreview"
>Preview</v-btn>
<span v-if="preview" class="text-body-2">
<strong>{{ preview.count }}</strong> image(s) would be deleted.
</span>
</div>
<v-btn
v-if="preview && preview.count > 0"
class="mt-3"
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete"
@click="onDeleteClick"
>Delete {{ preview.count }} matching...</v-btn>
<DestructiveConfirmModal
v-model="showModal"
@@ -55,7 +56,7 @@
:description="`Width < ${minW} OR height < ${minH}`"
@confirm="onConfirmedDelete"
/>
</v-card>
</MaintenanceTile>
</template>
<script setup>
@@ -64,7 +65,7 @@ import { onMounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
import { useCleanupStore } from '../../stores/cleanup.js'
import CardHeading from '../common/CardHeading.vue'
import MaintenanceTile from '../common/MaintenanceTile.vue'
// Backend's preview response hands the full Tier-C confirm token back
// as `confirm_token` (e.g. `delete-min-dim-1a2b3c4d`); passed straight
@@ -116,7 +117,3 @@ async function onConfirmedDelete(token) {
}
}
</script>
<style scoped>
.fc-clean-card { border-radius: 8px; }
</style>
@@ -1,79 +1,82 @@
<template>
<v-card class="fc-clean-card">
<CardHeading icon="mdi-palette-swatch" title="Single-color audit" />
<v-card-text>
<p class="fc-muted text-body-2 mb-3">
Scan library for images dominated by one color within the
tolerance. Catches placeholder / solid-fill / error-page images
that slipped through the import filter. Same background-scan
cadence as the transparency audit.
</p>
<MaintenanceTile
icon="mdi-palette-swatch"
title="Single-color audit"
blurb="Find & delete near-solid / placeholder images."
destructive
:open="audit?.status === 'running'"
>
<p class="fc-muted text-body-2 mb-3">
Scan library for images dominated by one color within the
tolerance. Catches placeholder / solid-fill / error-page images
that slipped through the import filter. Same background-scan
cadence as the transparency audit.
</p>
<v-row dense>
<v-col cols="6">
<v-text-field
v-model.number="threshold" label="Threshold (01)"
type="number" min="0" max="1" step="0.01"
density="compact" hide-details
:disabled="audit && audit.status === 'running'"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="tolerance" label="Color tolerance (0441)"
type="number" min="0" max="441"
density="compact" hide-details
:disabled="audit && audit.status === 'running'"
/>
</v-col>
</v-row>
<v-row dense>
<v-col cols="6">
<v-text-field
v-model.number="threshold" label="Threshold (01)"
type="number" min="0" max="1" step="0.01"
density="compact" hide-details
:disabled="audit && audit.status === 'running'"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="tolerance" label="Color tolerance (0441)"
type="number" min="0" max="441"
density="compact" hide-details
:disabled="audit && audit.status === 'running'"
/>
</v-col>
</v-row>
<v-btn
v-if="!audit || audit.status !== 'running'"
class="mt-3"
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify-scan"
:loading="busy"
@click="onStart"
>Scan library</v-btn>
<v-btn
v-if="!audit || audit.status !== 'running'"
class="mt-3"
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify-scan"
:loading="busy"
@click="onStart"
>Scan library</v-btn>
<div v-if="audit && audit.status === 'running'" class="mt-3">
<v-progress-linear indeterminate color="accent" />
<div class="text-body-2 mt-2 d-flex align-center" style="gap: 10px;">
<span>
Scanning {{ audit.scanned_count }} checked,
{{ audit.matched_count }} matched
</span>
<v-btn
variant="text" size="small" color="warning" rounded="pill"
@click="onCancel"
>Cancel</v-btn>
</div>
</div>
<div v-if="audit && audit.status === 'ready'" class="mt-3">
<p class="text-body-2 mb-2">
Scan complete. <strong>{{ audit.matched_count }}</strong>
image(s) match.
</p>
<div v-if="audit && audit.status === 'running'" class="mt-3">
<v-progress-linear indeterminate color="accent" />
<div class="text-body-2 mt-2 d-flex align-center" style="gap: 10px;">
<span>
Scanning {{ audit.scanned_count }} checked,
{{ audit.matched_count }} matched
</span>
<v-btn
v-if="audit.matched_count > 0"
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete"
@click="onApplyClick"
>Delete {{ audit.matched_count }} matching...</v-btn>
variant="text" size="small" color="warning" rounded="pill"
@click="onCancel"
>Cancel</v-btn>
</div>
</div>
<v-alert
v-if="audit && audit.status === 'error'"
type="error" variant="tonal" density="compact" class="mt-3"
>Scan failed: {{ audit.error }}</v-alert>
<div v-if="audit && audit.status === 'ready'" class="mt-3">
<p class="text-body-2 mb-2">
Scan complete. <strong>{{ audit.matched_count }}</strong>
image(s) match.
</p>
<v-btn
v-if="audit.matched_count > 0"
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete"
@click="onApplyClick"
>Delete {{ audit.matched_count }} matching...</v-btn>
</div>
<v-alert
v-if="audit && audit.status === 'applied'"
type="success" variant="tonal" density="compact" class="mt-3"
>Applied matched images deleted.</v-alert>
</v-card-text>
<v-alert
v-if="audit && audit.status === 'error'"
type="error" variant="tonal" density="compact" class="mt-3"
>Scan failed: {{ audit.error }}</v-alert>
<v-alert
v-if="audit && audit.status === 'applied'"
type="success" variant="tonal" density="compact" class="mt-3"
>Applied matched images deleted.</v-alert>
<DestructiveConfirmModal
v-if="audit"
@@ -86,7 +89,7 @@
description="Permanently deletes images matched by the single-color scan."
@confirm="onConfirmedApply"
/>
</v-card>
</MaintenanceTile>
</template>
<script setup>
@@ -94,7 +97,7 @@ import { toast } from '../../utils/toast.js'
import { onMounted, onUnmounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
import CardHeading from '../common/CardHeading.vue'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import { useCleanupStore } from '../../stores/cleanup.js'
const store = useCleanupStore()
@@ -185,7 +188,3 @@ async function onConfirmedApply(token) {
}
}
</script>
<style scoped>
.fc-clean-card { border-radius: 8px; }
</style>
@@ -1,66 +1,69 @@
<template>
<v-card class="fc-clean-card">
<CardHeading icon="mdi-checkerboard" title="Transparency audit" />
<v-card-text>
<p class="fc-muted text-body-2 mb-3">
Scan library for images whose transparent-pixel fraction exceeds
the threshold. Animated WebPs / GIFs are skipped (the import-side
rule does the same). Runs as a background task ~50ms per image,
so a 57k library takes ~50 minutes.
</p>
<MaintenanceTile
icon="mdi-checkerboard"
title="Transparency audit"
blurb="Find & delete images that are mostly transparent."
destructive
:open="audit?.status === 'running'"
>
<p class="fc-muted text-body-2 mb-3">
Scan library for images whose transparent-pixel fraction exceeds
the threshold. Animated WebPs / GIFs are skipped (the import-side
rule does the same). Runs as a background task ~50ms per image,
so a 57k library takes ~50 minutes.
</p>
<v-text-field
v-model.number="threshold" label="Transparency threshold (01)"
type="number" min="0" max="1" step="0.01" density="compact" hide-details
:disabled="audit && audit.status === 'running'"
class="mb-3"
/>
<v-text-field
v-model.number="threshold" label="Transparency threshold (01)"
type="number" min="0" max="1" step="0.01" density="compact" hide-details
:disabled="audit && audit.status === 'running'"
class="mb-3"
/>
<v-btn
v-if="!audit || audit.status !== 'running'"
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify-scan"
:loading="busy"
@click="onStart"
>Scan library</v-btn>
<v-btn
v-if="!audit || audit.status !== 'running'"
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify-scan"
:loading="busy"
@click="onStart"
>Scan library</v-btn>
<div v-if="audit && audit.status === 'running'" class="mt-3">
<v-progress-linear indeterminate color="accent" />
<div class="text-body-2 mt-2 d-flex align-center" style="gap: 10px;">
<span>
Scanning {{ audit.scanned_count }} checked,
{{ audit.matched_count }} matched
</span>
<v-btn
variant="text" size="small" color="warning" rounded="pill"
@click="onCancel"
>Cancel</v-btn>
</div>
</div>
<div v-if="audit && audit.status === 'ready'" class="mt-3">
<p class="text-body-2 mb-2">
Scan complete. <strong>{{ audit.matched_count }}</strong>
image(s) match.
</p>
<div v-if="audit && audit.status === 'running'" class="mt-3">
<v-progress-linear indeterminate color="accent" />
<div class="text-body-2 mt-2 d-flex align-center" style="gap: 10px;">
<span>
Scanning {{ audit.scanned_count }} checked,
{{ audit.matched_count }} matched
</span>
<v-btn
v-if="audit.matched_count > 0"
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete"
@click="onApplyClick"
>Delete {{ audit.matched_count }} matching...</v-btn>
variant="text" size="small" color="warning" rounded="pill"
@click="onCancel"
>Cancel</v-btn>
</div>
</div>
<v-alert
v-if="audit && audit.status === 'error'"
type="error" variant="tonal" density="compact" class="mt-3"
>Scan failed: {{ audit.error }}</v-alert>
<div v-if="audit && audit.status === 'ready'" class="mt-3">
<p class="text-body-2 mb-2">
Scan complete. <strong>{{ audit.matched_count }}</strong>
image(s) match.
</p>
<v-btn
v-if="audit.matched_count > 0"
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete"
@click="onApplyClick"
>Delete {{ audit.matched_count }} matching...</v-btn>
</div>
<v-alert
v-if="audit && audit.status === 'applied'"
type="success" variant="tonal" density="compact" class="mt-3"
>Applied matched images deleted.</v-alert>
</v-card-text>
<v-alert
v-if="audit && audit.status === 'error'"
type="error" variant="tonal" density="compact" class="mt-3"
>Scan failed: {{ audit.error }}</v-alert>
<v-alert
v-if="audit && audit.status === 'applied'"
type="success" variant="tonal" density="compact" class="mt-3"
>Applied matched images deleted.</v-alert>
<DestructiveConfirmModal
v-if="audit"
@@ -73,7 +76,7 @@
description="Permanently deletes images matched by the transparency scan."
@confirm="onConfirmedApply"
/>
</v-card>
</MaintenanceTile>
</template>
<script setup>
@@ -81,7 +84,7 @@ import { toast } from '../../utils/toast.js'
import { onMounted, onUnmounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
import CardHeading from '../common/CardHeading.vue'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import { useCleanupStore } from '../../stores/cleanup.js'
const store = useCleanupStore()
@@ -168,7 +171,3 @@ async function onConfirmedApply(token) {
}
}
</script>
<style scoped>
.fc-clean-card { border-radius: 8px; }
</style>
@@ -0,0 +1,121 @@
<!--
Compact, expandable maintenance/cleanup action tile. Collapsed it shows just an
icon + short title + one-line purpose, so a section of these tiles in a grid is
scannable at a glance; clicking the header expands the full controls / preview /
result UI inline (the operator opted for "compact tiles in a grid, detail on
expand", 2026-06-18, replacing the old long stack of full-width cards).
Usage: wrap a card's action body in the default slot; pass icon/title/blurb.
`destructive` tints the icon error-red for delete actions. `open` can be forced
(e.g. keep a running task's tile expanded). Keyboard accessible: the header is a
real <button> with aria-expanded + focus ring.
-->
<template>
<v-card class="fc-tile" :class="{ 'fc-tile--open': isOpen }">
<button
type="button"
class="fc-tile__head"
:aria-expanded="isOpen"
@click="toggle"
>
<v-icon
:icon="icon"
:color="destructive ? 'error' : 'accent'"
class="fc-tile__icon"
/>
<span class="fc-tile__text">
<span class="fc-tile__title">{{ title }}</span>
<span class="fc-tile__blurb">{{ blurb }}</span>
</span>
<v-icon
:icon="isOpen ? 'mdi-chevron-up' : 'mdi-chevron-down'"
class="fc-tile__chev"
/>
</button>
<v-expand-transition>
<div v-show="isOpen" class="fc-tile__body">
<slot />
</div>
</v-expand-transition>
</v-card>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
icon: { type: String, required: true },
title: { type: String, required: true },
blurb: { type: String, default: '' },
destructive: { type: Boolean, default: false },
// Force-open (e.g. a tile whose task is mid-run). When set, the operator can
// still collapse it locally, but a change to `open` re-applies.
open: { type: Boolean, default: false },
})
const local = ref(props.open)
watch(() => props.open, (v) => { local.value = v })
const isOpen = computed(() => local.value)
function toggle() {
local.value = !local.value
}
</script>
<style scoped>
.fc-tile {
border-radius: 8px;
overflow: hidden;
}
.fc-tile__head {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 14px;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
color: inherit;
}
.fc-tile__head:hover {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.fc-tile__head:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: -2px;
}
.fc-tile__icon {
flex: 0 0 auto;
}
.fc-tile__text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1 1 auto;
}
.fc-tile__title {
font-weight: 600;
font-size: 0.95rem;
line-height: 1.2;
}
.fc-tile__blurb {
font-size: 0.8rem;
line-height: 1.25;
color: rgb(var(--v-theme-on-surface-variant));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fc-tile--open .fc-tile__blurb {
white-space: normal;
}
.fc-tile__chev {
flex: 0 0 auto;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-tile__body {
padding: 0 14px 14px;
}
</style>
@@ -4,9 +4,13 @@
matches by content hash, so real content downloaded when access existed is
provably spared. Preview first, then apply (destructive: deletes the
matched blurred-preview files). -->
<v-card>
<v-card-title>Clean up gated-post previews</v-card-title>
<v-card-text>
<MaintenanceTile
icon="mdi-image-off"
title="Gated-post previews"
blurb="Delete blurred locked-preview images from tier-gated posts."
destructive
:open="applying || previewing"
>
<p class="text-body-2 mb-3">
Removes the blurred locked-preview images that were grabbed from
tier-gated Patreon posts before they were filtered out. It re-walks every
@@ -74,7 +78,6 @@
</v-alert>
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
</v-card-text>
<v-dialog v-model="confirmOpen" max-width="460">
<v-card>
@@ -92,13 +95,14 @@
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</MaintenanceTile>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue'
const confirmOpen = ref(false)
@@ -17,9 +17,11 @@
<DbMaintenanceCard class="mt-6" />
<ArchiveReextractCard class="mt-6" />
<MissingFileRepairCard class="mt-6" />
<VideoDedupCard class="mt-6" />
<GatedPurgeCard class="mt-6" />
<BackupCard class="mt-6" />
<!-- VideoDedupCard + GatedPurgeCard moved to the Cleanup tab (2026-06-18):
both are destructive content cleanup, so "remove unwanted stuff" now
lives in one place. -->
<!-- TODO(2026-06-18): tile-ify this tab's cards into sections too (pass 2). -->
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) — it
operates on the existing library which fits the Cleanup-tab
theme, and clusters with the other audit cards. LegacyMigrationCard
@@ -39,8 +41,6 @@ import AliasTable from './AliasTable.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue'
import ArchiveReextractCard from './ArchiveReextractCard.vue'
import MissingFileRepairCard from './MissingFileRepairCard.vue'
import VideoDedupCard from './VideoDedupCard.vue'
import GatedPurgeCard from './GatedPurgeCard.vue'
import BackupCard from './BackupCard.vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js'
@@ -1,100 +1,98 @@
<template>
<v-card class="fc-post-maint">
<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
their own nor a cross-posted duplicate) and no attachments. These are the
empty Post 12345 / (no description) shells a backfill can leave when a
post's only content was a duplicate that links to an earlier post. Posts
that gained a duplicate-image link are spared.
<MaintenanceTile
icon="mdi-post-outline"
title="Bare posts"
blurb="Delete empty post shells with no images or attachments."
destructive
>
<p class="fc-muted text-body-2 mb-3">
Post rows with no images (neither their own nor a cross-posted duplicate)
and no attachments the empty Post 12345 / (no description) shells a
backfill can leave when a post's only content was a duplicate that links to
an earlier post. Posts that gained a duplicate-image link are spared.
</p>
<v-alert
v-if="store.lastError"
type="warning" variant="tonal" density="compact" class="mb-3"
>{{ store.lastError }}</v-alert>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingPreview"
class="mb-3"
@click="onPreview"
>Preview bare posts</v-btn>
<div v-if="preview">
<p class="text-body-2 mb-2">
<strong>{{ preview.count }}</strong> bare post(s).
<span v-if="preview.count > 50" class="fc-muted">Showing first 50.</span>
<span v-else-if="!preview.count" class="fc-muted">Nothing to clean up.</span>
</p>
<v-alert
v-if="store.lastError"
type="warning" variant="tonal" density="compact" class="mb-3"
>{{ store.lastError }}</v-alert>
<SampleNameGrid
v-if="preview.sample_names?.length"
:names="preview.sample_names" class="mb-3"
/>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingPreview"
class="mb-3"
@click="onPreview"
>Preview bare posts</v-btn>
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-sweep"
:disabled="!preview.count"
:loading="committing"
@click="onCommit"
>Delete {{ preview.count }} bare post(s)</v-btn>
<span v-if="deleted != null" class="ml-3 text-caption text-success">
Deleted {{ deleted }} ✓
</span>
</div>
</MaintenanceTile>
<div v-if="preview">
<p class="text-body-2 mb-2">
<strong>{{ preview.count }}</strong> bare post(s).
<span v-if="preview.count > 50" class="fc-muted">
Showing first 50.
</span>
<span v-else-if="!preview.count" class="fc-muted">
Nothing to clean up.
</span>
</p>
<SampleNameGrid
v-if="preview.sample_names?.length"
:names="preview.sample_names" class="mb-3"
/>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-sweep"
:disabled="!preview.count"
:loading="committing"
@click="onCommit"
>Delete {{ preview.count }} bare post(s)</v-btn>
<span
v-if="deleted != null"
class="ml-3 text-caption text-success"
>Deleted {{ deleted }} ✓</span>
</div>
<MaintenanceTile
icon="mdi-merge"
title="Duplicate posts"
blurb="Merge gallery-dl + native duplicate post records into one."
destructive
>
<p class="fc-muted text-body-2 mb-3">
When an artist was first downloaded by gallery-dl and later re-walked by the
native ingester, the same post can exist twice (once keyed by the file's
attachment id, once by the real post id). This merges each pair onto one
canonical post (keeping the native post-id key), moving images, attachments
and links onto the survivor. Images themselves are never touched.
</p>
<v-divider class="my-5" />
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingDupPreview"
class="mb-3"
@click="onPreviewDupes"
>Preview duplicate posts</v-btn>
<p class="fc-muted text-body-2 mb-4">
Unify <strong>duplicate posts</strong> — when an artist was first
downloaded by gallery-dl and later re-walked by the native ingester, the
same post can exist twice (once keyed by the file's attachment id, once by
the real post id). This merges each pair onto one canonical post (keeping
the native post-id key), moving images, attachments and links onto the
survivor. Images themselves are never touched.
<div v-if="dupPreview">
<p class="text-body-2 mb-2">
<strong>{{ dupPreview.groups }}</strong> duplicate group(s),
<strong>{{ dupPreview.posts_to_merge }}</strong> redundant row(s) to merge.
<span v-if="dupPreview.groups > 50" class="fc-muted">Showing first 50.</span>
<span v-else-if="!dupPreview.groups" class="fc-muted">No duplicates found.</span>
</p>
<SampleNameGrid
v-if="dupSampleNames.length"
:names="dupSampleNames" class="mb-3"
/>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingDupPreview"
class="mb-3"
@click="onPreviewDupes"
>Preview duplicate posts</v-btn>
<div v-if="dupPreview">
<p class="text-body-2 mb-2">
<strong>{{ dupPreview.groups }}</strong> duplicate group(s),
<strong>{{ dupPreview.posts_to_merge }}</strong> redundant row(s) to
merge.
<span v-if="dupPreview.groups > 50" class="fc-muted">Showing first 50.</span>
<span v-else-if="!dupPreview.groups" class="fc-muted">No duplicates found.</span>
</p>
<SampleNameGrid
v-if="dupSampleNames.length"
:names="dupSampleNames" class="mb-3"
/>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-merge"
:disabled="!dupPreview.groups"
:loading="merging"
@click="onMergeDupes"
>Merge {{ dupPreview.posts_to_merge }} duplicate(s)</v-btn>
<span
v-if="merged != null"
class="ml-3 text-caption text-success"
>Merged {{ merged }} </span>
</div>
</v-card-text>
</v-card>
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-merge"
:disabled="!dupPreview.groups"
:loading="merging"
@click="onMergeDupes"
>Merge {{ dupPreview.posts_to_merge }} duplicate(s)</v-btn>
<span v-if="merged != null" class="ml-3 text-caption text-success">
Merged {{ merged }}
</span>
</div>
</MaintenanceTile>
</template>
<script setup>
@@ -102,7 +100,7 @@ import { computed, ref } from 'vue'
import { useAdminStore } from '../../stores/admin.js'
import SampleNameGrid from '../common/SampleNameGrid.vue'
import CardHeading from '../common/CardHeading.vue'
import MaintenanceTile from '../common/MaintenanceTile.vue'
const store = useAdminStore()
const preview = ref(null)
@@ -165,7 +163,3 @@ async function onMergeDupes() {
}
}
</script>
<style scoped>
.fc-post-maint { border-radius: 8px; }
</style>
@@ -1,191 +1,201 @@
<template>
<v-card class="fc-tag-maint">
<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
references. Auto-created tag rows that never got applied get
swept here.
</p>
<v-alert
v-if="store.lastError"
type="warning" variant="tonal" density="compact" class="mb-3"
>{{ store.lastError }}</v-alert>
<v-alert
v-if="store.lastError"
type="warning" variant="tonal" density="compact" class="mb-3"
>{{ store.lastError }}</v-alert>
<MaintenanceTile
icon="mdi-tag-remove"
title="Unused tags"
blurb="Delete tags with no image or series-page references."
destructive
>
<p class="fc-muted text-body-2 mb-3">
Remove tags with zero image associations and zero series-page
references. Auto-created tag rows that never got applied get swept here.
</p>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingPreview"
class="mb-3"
@click="onPreview"
>Preview unused tags</v-btn>
<div v-if="preview">
<p class="text-body-2 mb-2">
<strong>{{ preview.count }}</strong> unused tag(s).
<span v-if="preview.count > 50" class="fc-muted">
Showing first 50 names.
</span>
</p>
<SampleNameGrid
v-if="preview.sample_names?.length"
:names="preview.sample_names" class="mb-3"
/>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-sweep"
:disabled="!preview.count"
:loading="committing"
@click="onCommit"
>Delete {{ preview.count }} unused tag(s)</v-btn>
</div>
<v-divider class="my-5" />
<p class="fc-muted text-body-2 mb-4">
Purge legacy IR-migration tags FC no longer uses: retired/system
kinds (<code>archive</code>, <code>post</code>,
<code>artist</code> e.g.
<code>BlenderKnight:Hannah_BJ_Loops</code>) plus
<code>source:*</code> tags (ImageRepo's old <code>source</code>
kind, which migrated to <code>general</code>). Provenance and
artists are their own systems now, so these are pure noise.
Removes them from every image.
</p>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingKindPreview"
class="mb-3"
@click="onKindPreview"
>Preview legacy tags</v-btn>
<div v-if="kindPreview">
<p class="text-body-2 mb-2">
<strong>{{ kindPreview.count }}</strong> legacy tag(s).
<span v-for="(n, k) in kindPreview.by_kind" :key="k" class="fc-muted">
{{ k }}: {{ n }}&nbsp;&nbsp;
</span>
<span v-for="(n, p) in kindPreview.by_prefix" :key="p" class="fc-muted">
{{ p }}: {{ n }}&nbsp;&nbsp;
</span>
</p>
<SampleNameGrid
v-if="kindPreview.sample_names?.length"
:names="kindPreview.sample_names" class="mb-3"
/>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-sweep"
:disabled="!kindPreview.count"
:loading="kindCommitting"
@click="onKindCommit"
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
</div>
<v-divider class="my-5" />
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingPreview"
class="mb-3"
@click="onPreview"
>Preview unused tags</v-btn>
<div v-if="preview">
<p class="text-body-2 mb-2">
<strong class="text-error">Reset content tagging.</strong>
Deletes every <code>general</code> and <code>character</code> tag and
removes them from every image, so you can re-tag from scratch with the
auto-suggest. <strong>Fandoms and series (with their page order) are
kept</strong>, and each image's saved predictions are untouched open
an image and its suggestions reappear.
<strong>{{ preview.count }}</strong> unused tag(s).
<span v-if="preview.count > 50" class="fc-muted">Showing first 50 names.</span>
</p>
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
Irreversible there's no undo except restoring a DB backup.
Back one up first (Settings → Maintenance → Backup).
</v-alert>
<SampleNameGrid
v-if="preview.sample_names?.length"
:names="preview.sample_names" class="mb-3"
/>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingResetPreview"
class="mb-3"
@click="onResetPreview"
>Preview content-tag reset</v-btn>
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-sweep"
:disabled="!preview.count"
:loading="committing"
@click="onCommit"
>Delete {{ preview.count }} unused tag(s)</v-btn>
</div>
</MaintenanceTile>
<div v-if="resetPreview">
<p class="text-body-2 mb-2">
<strong>{{ resetPreview.count }}</strong> content tag(s)
<span v-for="(n, k) in resetPreview.by_kind" :key="k" class="fc-muted">
({{ k }}: {{ n }})&nbsp;
</span>
across <strong>{{ resetPreview.applications }}</strong> image
application(s).
</p>
<SampleNameGrid
v-if="resetPreview.sample_names?.length"
:names="resetPreview.sample_names" class="mb-3"
/>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-alert"
:disabled="!resetPreview.count"
:loading="resetCommitting"
@click="onResetCommit"
>Delete {{ resetPreview.count }} content tag(s) +
{{ resetPreview.applications }} application(s)</v-btn>
</div>
<MaintenanceTile
icon="mdi-tag-off"
title="Legacy migration tags"
blurb="Purge retired archive/post/artist + source:* tags."
destructive
>
<p class="fc-muted text-body-2 mb-3">
Purge legacy IR-migration tags FC no longer uses: retired/system
kinds (<code>archive</code>, <code>post</code>, <code>artist</code> e.g.
<code>BlenderKnight:Hannah_BJ_Loops</code>) plus <code>source:*</code> tags
(ImageRepo's old <code>source</code> kind, migrated to <code>general</code>).
Provenance and artists are their own systems now, so these are pure noise.
Removes them from every image.
</p>
<v-divider class="my-5" />
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingKindPreview"
class="mb-3"
@click="onKindPreview"
>Preview legacy tags</v-btn>
<!-- #714: standardize existing tags to the canonical Title-Case form
and merge case/whitespace-variant duplicates. -->
<div v-if="kindPreview">
<p class="text-body-2 mb-2">
<strong>Standardize tag casing.</strong>
New tags are saved Title Case, but older tags keep whatever casing they
were created with. This renames every existing tag to
<code>Title Case</code> (collapsing extra spaces) and
<strong>merges</strong> tags that differ only by case or spacing
(e.g. <code>hatsune miku</code> + <code>Hatsune&nbsp;&nbsp;Miku</code>)
into one — repointing their images, allowlist entries and series pages.
</p>
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
The merges are irreversible — back up first
(Settings → Maintenance → Backup). Safe to run more than once.
</v-alert>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingNormPreview"
class="mb-3"
@click="onNormPreview"
>Preview tag standardization</v-btn>
<div v-if="normPreview">
<p class="text-body-2 mb-2">
<strong>{{ normPreview.total_changes }}</strong> tag group(s) to
change — <strong>{{ normPreview.tags_to_rename }}</strong> rename(s),
<strong>{{ normPreview.collisions }}</strong> collision(s) merging
<strong>{{ normPreview.tags_to_merge }}</strong> tag(s) away.
</p>
<SampleNameGrid v-if="normPreview.sample?.length" class="mb-3">
<span
v-for="s in normPreview.sample" :key="s.to + s.kind"
class="fc-name"
>
<template v-if="s.merge">{{ s.from.join(' + ') }} → </template>{{ s.to }}
</span>
</SampleNameGrid>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-format-letter-case"
:disabled="!normPreview.total_changes || normResult === 'queued'"
:loading="normCommitting"
@click="onNormCommit"
>Standardize {{ normPreview.total_changes }} tag group(s)</v-btn>
<span v-if="normResult === 'queued'" class="ml-3 text-caption text-success">
Queued ✓ — runs in the background (you can leave this page). It
processes in chunks, so re-run “Preview” later to confirm it's all done.
<strong>{{ kindPreview.count }}</strong> legacy tag(s).
<span v-for="(n, k) in kindPreview.by_kind" :key="k" class="fc-muted">
{{ k }}: {{ n }}&nbsp;&nbsp;
</span>
</div>
</v-card-text>
</v-card>
<span v-for="(n, p) in kindPreview.by_prefix" :key="p" class="fc-muted">
{{ p }}: {{ n }}&nbsp;&nbsp;
</span>
</p>
<SampleNameGrid
v-if="kindPreview.sample_names?.length"
:names="kindPreview.sample_names" class="mb-3"
/>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-sweep"
:disabled="!kindPreview.count"
:loading="kindCommitting"
@click="onKindCommit"
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
</div>
</MaintenanceTile>
<MaintenanceTile
icon="mdi-tag-multiple"
title="Reset content tagging"
blurb="Delete all general/character tags to re-tag from scratch."
destructive
>
<p class="text-body-2 mb-2">
Deletes every <code>general</code> and <code>character</code> tag and
removes them from every image, so you can re-tag from scratch with the
auto-suggest. <strong>Fandoms and series (with their page order) are
kept</strong>, and each image's saved predictions are untouched open
an image and its suggestions reappear.
</p>
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
Irreversible there's no undo except restoring a DB backup.
Back one up first (Settings → Maintenance → Backup).
</v-alert>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingResetPreview"
class="mb-3"
@click="onResetPreview"
>Preview content-tag reset</v-btn>
<div v-if="resetPreview">
<p class="text-body-2 mb-2">
<strong>{{ resetPreview.count }}</strong> content tag(s)
<span v-for="(n, k) in resetPreview.by_kind" :key="k" class="fc-muted">
({{ k }}: {{ n }})&nbsp;
</span>
across <strong>{{ resetPreview.applications }}</strong> image
application(s).
</p>
<SampleNameGrid
v-if="resetPreview.sample_names?.length"
:names="resetPreview.sample_names" class="mb-3"
/>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-alert"
:disabled="!resetPreview.count"
:loading="resetCommitting"
@click="onResetCommit"
>Delete {{ resetPreview.count }} content tag(s) +
{{ resetPreview.applications }} application(s)</v-btn>
</div>
</MaintenanceTile>
<MaintenanceTile
icon="mdi-format-letter-case"
title="Standardize tag casing"
blurb="Title-case all tags & merge case/space-variant duplicates."
destructive
>
<!-- #714: standardize existing tags to the canonical Title-Case form
and merge case/whitespace-variant duplicates. -->
<p class="text-body-2 mb-2">
New tags are saved Title Case, but older tags keep whatever casing they
were created with. This renames every existing tag to <code>Title Case</code>
(collapsing extra spaces) and <strong>merges</strong> tags that differ only
by case or spacing (e.g. <code>hatsune miku</code> +
<code>Hatsune&nbsp;&nbsp;Miku</code>) into one — repointing their images,
allowlist entries and series pages.
</p>
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
The merges are irreversible — back up first
(Settings → Maintenance → Backup). Safe to run more than once.
</v-alert>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingNormPreview"
class="mb-3"
@click="onNormPreview"
>Preview tag standardization</v-btn>
<div v-if="normPreview">
<p class="text-body-2 mb-2">
<strong>{{ normPreview.total_changes }}</strong> tag group(s) to
change — <strong>{{ normPreview.tags_to_rename }}</strong> rename(s),
<strong>{{ normPreview.collisions }}</strong> collision(s) merging
<strong>{{ normPreview.tags_to_merge }}</strong> tag(s) away.
</p>
<SampleNameGrid v-if="normPreview.sample?.length" class="mb-3">
<span
v-for="s in normPreview.sample" :key="s.to + s.kind"
class="fc-name"
>
<template v-if="s.merge">{{ s.from.join(' + ') }} → </template>{{ s.to }}
</span>
</SampleNameGrid>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-format-letter-case"
:disabled="!normPreview.total_changes || normResult === 'queued'"
:loading="normCommitting"
@click="onNormCommit"
>Standardize {{ normPreview.total_changes }} tag group(s)</v-btn>
<span v-if="normResult === 'queued'" class="ml-3 text-caption text-success">
Queued ✓ — runs in the background (you can leave this page). It
processes in chunks, so re-run “Preview” later to confirm it's all done.
</span>
</div>
</MaintenanceTile>
</template>
<script setup>
@@ -193,7 +203,7 @@ import { ref } from 'vue'
import { useAdminStore } from '../../stores/admin.js'
import SampleNameGrid from '../common/SampleNameGrid.vue'
import CardHeading from '../common/CardHeading.vue'
import MaintenanceTile from '../common/MaintenanceTile.vue'
const store = useAdminStore()
const preview = ref(null)
@@ -287,17 +297,8 @@ async function onNormCommit() {
// confirm it's queued; the operator can re-run Preview later to verify.
await store.normalizeTags({ dryRun: false })
normResult.value = 'queued'
// Keep the preview showing what was QUEUED — do NOT zero it out. Overwriting
// it with a zeroed object made the card read "0 tag groups to change" the
// instant Standardize was clicked, which looked like nothing happened
// (operator-flagged 2026-06-07). The button disables on `queued`; re-running
// Preview later reflects the real remaining count as the task applies.
} finally {
normCommitting.value = false
}
}
</script>
<style scoped>
.fc-tag-maint { border-radius: 8px; }
</style>
@@ -3,54 +3,57 @@
duration + aspect (the same clip re-encoded / pulled from multiple
sources). Preview first, then apply (destructive: removes the redundant
copies, keeping the highest-resolution one). -->
<v-card>
<v-card-title>Deduplicate videos</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">
Finds videos of the same artist that are the same content across
re-encodes (matching duration + aspect ratio) and keeps the
highest-resolution copy. <strong>Preview</strong> first to see what would
be removed; <strong>Apply</strong> re-points each post to the kept copy,
then deletes the redundant videos and their files. The first run also
computes durations for older videos, so it may take a while.
</p>
<MaintenanceTile
icon="mdi-content-duplicate"
title="Deduplicate videos"
blurb="Collapse re-encoded duplicate videos, keeping the best copy."
destructive
:open="applying || previewing"
>
<p class="text-body-2 mb-3">
Finds videos of the same artist that are the same content across
re-encodes (matching duration + aspect ratio) and keeps the
highest-resolution copy. <strong>Preview</strong> first to see what would
be removed; <strong>Apply</strong> re-points each post to the kept copy,
then deletes the redundant videos and their files. The first run also
computes durations for older videos, so it may take a while.
</p>
<div class="d-flex align-center flex-wrap" style="gap: 12px;">
<v-btn
color="primary" variant="tonal" rounded="pill"
:loading="previewing" :disabled="applying" @click="preview"
>
<v-icon start>mdi-magnify</v-icon> Preview
</v-btn>
<v-btn
color="error" rounded="pill"
:loading="applying"
:disabled="previewing || !canApply"
@click="confirmOpen = true"
>
<v-icon start>mdi-content-duplicate</v-icon> Apply
</v-btn>
</div>
<v-alert
v-if="summary" :type="summaryType" variant="tonal" class="mt-4"
density="comfortable"
<div class="d-flex align-center flex-wrap" style="gap: 12px;">
<v-btn
color="primary" variant="tonal" rounded="pill"
:loading="previewing" :disabled="applying" @click="preview"
>
<span v-if="applied">
Removed {{ summary.deleted }} redundant video(s) across
{{ summary.groups }} group(s); re-pointed {{ summary.relinked_posts }}
post link(s); reclaimed {{ humanBytes(summary.reclaim_bytes) }}.
</span>
<span v-else-if="summary.redundant > 0">
{{ summary.redundant }} redundant video(s) across {{ summary.groups }}
group(s) {{ humanBytes(summary.reclaim_bytes) }} reclaimable. Click
<strong>Apply</strong> to remove them (keeping the best copy).
</span>
<span v-else>No duplicate videos found.</span>
</v-alert>
<v-icon start>mdi-magnify</v-icon> Preview
</v-btn>
<v-btn
color="error" rounded="pill"
:loading="applying"
:disabled="previewing || !canApply"
@click="confirmOpen = true"
>
<v-icon start>mdi-content-duplicate</v-icon> Apply
</v-btn>
</div>
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
</v-card-text>
<v-alert
v-if="summary" :type="summaryType" variant="tonal" class="mt-4"
density="comfortable"
>
<span v-if="applied">
Removed {{ summary.deleted }} redundant video(s) across
{{ summary.groups }} group(s); re-pointed {{ summary.relinked_posts }}
post link(s); reclaimed {{ humanBytes(summary.reclaim_bytes) }}.
</span>
<span v-else-if="summary.redundant > 0">
{{ summary.redundant }} redundant video(s) across {{ summary.groups }}
group(s) {{ humanBytes(summary.reclaim_bytes) }} reclaimable. Click
<strong>Apply</strong> to remove them (keeping the best copy).
</span>
<span v-else>No duplicate videos found.</span>
</v-alert>
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
<v-dialog v-model="confirmOpen" max-width="440">
<v-card>
@@ -68,13 +71,14 @@
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</MaintenanceTile>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue'
const confirmOpen = ref(false)
+60 -15
View File
@@ -1,16 +1,44 @@
<template>
<div class="fc-cleanup">
<p class="fc-muted text-body-2 mb-4">
Retroactive enforcement of import-filter rules. Each card scans the
existing library for content that the current import filters would
now exclude. Destructive typed-token confirmation required.
<p class="fc-muted text-body-2 mb-5">
Retroactive enforcement and cleanup on the existing library each tool
previews before it acts, and destructive ones need a typed-token confirm.
Click a tile to open its controls.
</p>
<MinDimensionCard class="mb-4" />
<TransparencyAuditCard class="mb-4" />
<SingleColorAuditCard class="mb-4" />
<PostMaintenanceCard class="mb-4" />
<TagMaintenanceCard />
<section class="fc-section">
<h3 class="fc-section__title">Import-filter audits</h3>
<p class="fc-section__hint">
Scan for content the current import filters would now exclude.
</p>
<div class="fc-tile-grid">
<MinDimensionCard />
<TransparencyAuditCard />
<SingleColorAuditCard />
</div>
</section>
<section class="fc-section">
<h3 class="fc-section__title">Duplicates &amp; posts</h3>
<p class="fc-section__hint">
Tidy post records, duplicates and locked-preview leftovers.
</p>
<div class="fc-tile-grid">
<PostMaintenanceCard />
<VideoDedupCard />
<GatedPurgeCard />
</div>
</section>
<section class="fc-section">
<h3 class="fc-section__title">Tags</h3>
<p class="fc-section__hint">
Remove unused / legacy tags and standardize the existing set.
</p>
<div class="fc-tile-grid">
<TagMaintenanceCard />
</div>
</section>
</div>
</template>
@@ -19,14 +47,31 @@ import MinDimensionCard from '../components/cleanup/MinDimensionCard.vue'
import TransparencyAuditCard from '../components/cleanup/TransparencyAuditCard.vue'
import SingleColorAuditCard from '../components/cleanup/SingleColorAuditCard.vue'
import PostMaintenanceCard from '../components/settings/PostMaintenanceCard.vue'
// Reuse existing TagMaintenanceCard (FC-3k) as-is — it already handles
// preview + commit of prune-unused-tags via the admin store. Operator
// confirmed 2026-05-26: don't duplicate into a new UnusedTagsCard.
// MaintenancePanel drops its TagMaintenanceCard reference in the
// SettingsView edit (Task 16) so this is now the sole rendering site.
import VideoDedupCard from '../components/settings/VideoDedupCard.vue'
import GatedPurgeCard from '../components/settings/GatedPurgeCard.vue'
import TagMaintenanceCard from '../components/settings/TagMaintenanceCard.vue'
</script>
<style scoped>
.fc-cleanup { max-width: 900px; }
.fc-cleanup { max-width: 1100px; }
.fc-section { margin-bottom: 28px; }
.fc-section__title {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(var(--v-theme-accent));
margin-bottom: 2px;
}
.fc-section__hint {
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 12px;
}
.fc-tile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 14px;
align-items: start;
}
</style>