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,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>