Merge pull request 'feat(settings): tidy Cleanup tab into sectioned compact tiles (pass 1)' (#125) from dev into main
CI / backend-lint-and-test (push) Failing after 2s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m19s

This commit was merged in pull request #125.
This commit is contained in:
2026-06-17 23:49:21 -04:00
10 changed files with 697 additions and 533 deletions
@@ -1,49 +1,50 @@
<template> <template>
<v-card class="fc-clean-card"> <MaintenanceTile
<CardHeading icon="mdi-image-size-select-small" title="Minimum dimensions" /> icon="mdi-image-size-select-small"
<v-card-text> title="Minimum dimensions"
<p class="fc-muted text-body-2 mb-3"> blurb="Delete images below the import min-width / min-height threshold."
Find and delete images smaller than the threshold. Mirrors the destructive
import-time <code>min_width</code> / <code>min_height</code> >
filter, applied retroactively to the existing library. <p class="fc-muted text-body-2 mb-3">
</p> Mirrors the import-time <code>min_width</code> / <code>min_height</code>
filter, applied retroactively to the existing library.
</p>
<v-row dense> <v-row dense>
<v-col cols="6"> <v-col cols="6">
<v-text-field <v-text-field
v-model.number="minW" label="Min width (px)" type="number" v-model.number="minW" label="Min width (px)" type="number"
min="0" density="compact" hide-details min="0" density="compact" hide-details
/> />
</v-col> </v-col>
<v-col cols="6"> <v-col cols="6">
<v-text-field <v-text-field
v-model.number="minH" label="Min height (px)" type="number" v-model.number="minH" label="Min height (px)" type="number"
min="0" density="compact" hide-details min="0" density="compact" hide-details
/> />
</v-col> </v-col>
</v-row> </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>
<div class="d-flex align-center mt-3" style="gap: 10px;">
<v-btn <v-btn
v-if="preview && preview.count > 0" color="accent" variant="flat" rounded="pill"
class="mt-3" prepend-icon="mdi-magnify"
color="error" variant="flat" rounded="pill" :loading="busy"
prepend-icon="mdi-delete" @click="onPreview"
@click="onDeleteClick" >Preview</v-btn>
>Delete {{ preview.count }} matching...</v-btn>
</v-card-text> <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 <DestructiveConfirmModal
v-model="showModal" v-model="showModal"
@@ -55,7 +56,7 @@
:description="`Width < ${minW} OR height < ${minH}`" :description="`Width < ${minW} OR height < ${minH}`"
@confirm="onConfirmedDelete" @confirm="onConfirmedDelete"
/> />
</v-card> </MaintenanceTile>
</template> </template>
<script setup> <script setup>
@@ -64,7 +65,7 @@ import { onMounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue' import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
import { useCleanupStore } from '../../stores/cleanup.js' 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 // Backend's preview response hands the full Tier-C confirm token back
// as `confirm_token` (e.g. `delete-min-dim-1a2b3c4d`); passed straight // as `confirm_token` (e.g. `delete-min-dim-1a2b3c4d`); passed straight
@@ -116,7 +117,3 @@ async function onConfirmedDelete(token) {
} }
} }
</script> </script>
<style scoped>
.fc-clean-card { border-radius: 8px; }
</style>
@@ -1,79 +1,82 @@
<template> <template>
<v-card class="fc-clean-card"> <MaintenanceTile
<CardHeading icon="mdi-palette-swatch" title="Single-color audit" /> icon="mdi-palette-swatch"
<v-card-text> title="Single-color audit"
<p class="fc-muted text-body-2 mb-3"> blurb="Find & delete near-solid / placeholder images."
Scan library for images dominated by one color within the destructive
tolerance. Catches placeholder / solid-fill / error-page images :open="audit?.status === 'running'"
that slipped through the import filter. Same background-scan >
cadence as the transparency audit. <p class="fc-muted text-body-2 mb-3">
</p> 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-row dense>
<v-col cols="6"> <v-col cols="6">
<v-text-field <v-text-field
v-model.number="threshold" label="Threshold (01)" v-model.number="threshold" label="Threshold (01)"
type="number" min="0" max="1" step="0.01" type="number" min="0" max="1" step="0.01"
density="compact" hide-details density="compact" hide-details
:disabled="audit && audit.status === 'running'" :disabled="audit && audit.status === 'running'"
/> />
</v-col> </v-col>
<v-col cols="6"> <v-col cols="6">
<v-text-field <v-text-field
v-model.number="tolerance" label="Color tolerance (0441)" v-model.number="tolerance" label="Color tolerance (0441)"
type="number" min="0" max="441" type="number" min="0" max="441"
density="compact" hide-details density="compact" hide-details
:disabled="audit && audit.status === 'running'" :disabled="audit && audit.status === 'running'"
/> />
</v-col> </v-col>
</v-row> </v-row>
<v-btn <v-btn
v-if="!audit || audit.status !== 'running'" v-if="!audit || audit.status !== 'running'"
class="mt-3" class="mt-3"
color="accent" variant="flat" rounded="pill" color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify-scan" prepend-icon="mdi-magnify-scan"
:loading="busy" :loading="busy"
@click="onStart" @click="onStart"
>Scan library</v-btn> >Scan library</v-btn>
<div v-if="audit && audit.status === 'running'" class="mt-3"> <div v-if="audit && audit.status === 'running'" class="mt-3">
<v-progress-linear indeterminate color="accent" /> <v-progress-linear indeterminate color="accent" />
<div class="text-body-2 mt-2 d-flex align-center" style="gap: 10px;"> <div class="text-body-2 mt-2 d-flex align-center" style="gap: 10px;">
<span> <span>
Scanning {{ audit.scanned_count }} checked, Scanning {{ audit.scanned_count }} checked,
{{ audit.matched_count }} matched {{ audit.matched_count }} matched
</span> </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>
<v-btn <v-btn
v-if="audit.matched_count > 0" variant="text" size="small" color="warning" rounded="pill"
color="error" variant="flat" rounded="pill" @click="onCancel"
prepend-icon="mdi-delete" >Cancel</v-btn>
@click="onApplyClick"
>Delete {{ audit.matched_count }} matching...</v-btn>
</div> </div>
</div>
<v-alert <div v-if="audit && audit.status === 'ready'" class="mt-3">
v-if="audit && audit.status === 'error'" <p class="text-body-2 mb-2">
type="error" variant="tonal" density="compact" class="mt-3" Scan complete. <strong>{{ audit.matched_count }}</strong>
>Scan failed: {{ audit.error }}</v-alert> 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-alert
v-if="audit && audit.status === 'applied'" v-if="audit && audit.status === 'error'"
type="success" variant="tonal" density="compact" class="mt-3" type="error" variant="tonal" density="compact" class="mt-3"
>Applied matched images deleted.</v-alert> >Scan failed: {{ audit.error }}</v-alert>
</v-card-text>
<v-alert
v-if="audit && audit.status === 'applied'"
type="success" variant="tonal" density="compact" class="mt-3"
>Applied matched images deleted.</v-alert>
<DestructiveConfirmModal <DestructiveConfirmModal
v-if="audit" v-if="audit"
@@ -86,7 +89,7 @@
description="Permanently deletes images matched by the single-color scan." description="Permanently deletes images matched by the single-color scan."
@confirm="onConfirmedApply" @confirm="onConfirmedApply"
/> />
</v-card> </MaintenanceTile>
</template> </template>
<script setup> <script setup>
@@ -94,7 +97,7 @@ import { toast } from '../../utils/toast.js'
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.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' import { useCleanupStore } from '../../stores/cleanup.js'
const store = useCleanupStore() const store = useCleanupStore()
@@ -185,7 +188,3 @@ async function onConfirmedApply(token) {
} }
} }
</script> </script>
<style scoped>
.fc-clean-card { border-radius: 8px; }
</style>
@@ -1,66 +1,69 @@
<template> <template>
<v-card class="fc-clean-card"> <MaintenanceTile
<CardHeading icon="mdi-checkerboard" title="Transparency audit" /> icon="mdi-checkerboard"
<v-card-text> title="Transparency audit"
<p class="fc-muted text-body-2 mb-3"> blurb="Find & delete images that are mostly transparent."
Scan library for images whose transparent-pixel fraction exceeds destructive
the threshold. Animated WebPs / GIFs are skipped (the import-side :open="audit?.status === 'running'"
rule does the same). Runs as a background task ~50ms per image, >
so a 57k library takes ~50 minutes. <p class="fc-muted text-body-2 mb-3">
</p> 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-text-field
v-model.number="threshold" label="Transparency threshold (01)" v-model.number="threshold" label="Transparency threshold (01)"
type="number" min="0" max="1" step="0.01" density="compact" hide-details type="number" min="0" max="1" step="0.01" density="compact" hide-details
:disabled="audit && audit.status === 'running'" :disabled="audit && audit.status === 'running'"
class="mb-3" class="mb-3"
/> />
<v-btn <v-btn
v-if="!audit || audit.status !== 'running'" v-if="!audit || audit.status !== 'running'"
color="accent" variant="flat" rounded="pill" color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify-scan" prepend-icon="mdi-magnify-scan"
:loading="busy" :loading="busy"
@click="onStart" @click="onStart"
>Scan library</v-btn> >Scan library</v-btn>
<div v-if="audit && audit.status === 'running'" class="mt-3"> <div v-if="audit && audit.status === 'running'" class="mt-3">
<v-progress-linear indeterminate color="accent" /> <v-progress-linear indeterminate color="accent" />
<div class="text-body-2 mt-2 d-flex align-center" style="gap: 10px;"> <div class="text-body-2 mt-2 d-flex align-center" style="gap: 10px;">
<span> <span>
Scanning {{ audit.scanned_count }} checked, Scanning {{ audit.scanned_count }} checked,
{{ audit.matched_count }} matched {{ audit.matched_count }} matched
</span> </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>
<v-btn <v-btn
v-if="audit.matched_count > 0" variant="text" size="small" color="warning" rounded="pill"
color="error" variant="flat" rounded="pill" @click="onCancel"
prepend-icon="mdi-delete" >Cancel</v-btn>
@click="onApplyClick"
>Delete {{ audit.matched_count }} matching...</v-btn>
</div> </div>
</div>
<v-alert <div v-if="audit && audit.status === 'ready'" class="mt-3">
v-if="audit && audit.status === 'error'" <p class="text-body-2 mb-2">
type="error" variant="tonal" density="compact" class="mt-3" Scan complete. <strong>{{ audit.matched_count }}</strong>
>Scan failed: {{ audit.error }}</v-alert> 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-alert
v-if="audit && audit.status === 'applied'" v-if="audit && audit.status === 'error'"
type="success" variant="tonal" density="compact" class="mt-3" type="error" variant="tonal" density="compact" class="mt-3"
>Applied matched images deleted.</v-alert> >Scan failed: {{ audit.error }}</v-alert>
</v-card-text>
<v-alert
v-if="audit && audit.status === 'applied'"
type="success" variant="tonal" density="compact" class="mt-3"
>Applied matched images deleted.</v-alert>
<DestructiveConfirmModal <DestructiveConfirmModal
v-if="audit" v-if="audit"
@@ -73,7 +76,7 @@
description="Permanently deletes images matched by the transparency scan." description="Permanently deletes images matched by the transparency scan."
@confirm="onConfirmedApply" @confirm="onConfirmedApply"
/> />
</v-card> </MaintenanceTile>
</template> </template>
<script setup> <script setup>
@@ -81,7 +84,7 @@ import { toast } from '../../utils/toast.js'
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.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' import { useCleanupStore } from '../../stores/cleanup.js'
const store = useCleanupStore() const store = useCleanupStore()
@@ -168,7 +171,3 @@ async function onConfirmedApply(token) {
} }
} }
</script> </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 matches by content hash, so real content downloaded when access existed is
provably spared. Preview first, then apply (destructive: deletes the provably spared. Preview first, then apply (destructive: deletes the
matched blurred-preview files). --> matched blurred-preview files). -->
<v-card> <MaintenanceTile
<v-card-title>Clean up gated-post previews</v-card-title> icon="mdi-image-off"
<v-card-text> 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"> <p class="text-body-2 mb-3">
Removes the blurred locked-preview images that were grabbed from Removes the blurred locked-preview images that were grabbed from
tier-gated Patreon posts before they were filtered out. It re-walks every tier-gated Patreon posts before they were filtered out. It re-walks every
@@ -74,7 +78,6 @@
</v-alert> </v-alert>
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" /> <QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
</v-card-text>
<v-dialog v-model="confirmOpen" max-width="460"> <v-dialog v-model="confirmOpen" max-width="460">
<v-card> <v-card>
@@ -92,13 +95,14 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
</v-card> </MaintenanceTile>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js' import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue' import QueueStatusBar from './QueueStatusBar.vue'
const confirmOpen = ref(false) const confirmOpen = ref(false)
@@ -17,9 +17,11 @@
<DbMaintenanceCard class="mt-6" /> <DbMaintenanceCard class="mt-6" />
<ArchiveReextractCard class="mt-6" /> <ArchiveReextractCard class="mt-6" />
<MissingFileRepairCard class="mt-6" /> <MissingFileRepairCard class="mt-6" />
<VideoDedupCard class="mt-6" />
<GatedPurgeCard class="mt-6" />
<BackupCard 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 <!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) — it
operates on the existing library which fits the Cleanup-tab operates on the existing library which fits the Cleanup-tab
theme, and clusters with the other audit cards. LegacyMigrationCard theme, and clusters with the other audit cards. LegacyMigrationCard
@@ -39,8 +41,6 @@ import AliasTable from './AliasTable.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue' import DbMaintenanceCard from './DbMaintenanceCard.vue'
import ArchiveReextractCard from './ArchiveReextractCard.vue' import ArchiveReextractCard from './ArchiveReextractCard.vue'
import MissingFileRepairCard from './MissingFileRepairCard.vue' import MissingFileRepairCard from './MissingFileRepairCard.vue'
import VideoDedupCard from './VideoDedupCard.vue'
import GatedPurgeCard from './GatedPurgeCard.vue'
import BackupCard from './BackupCard.vue' import BackupCard from './BackupCard.vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js' import { useSystemActivityStore } from '../../stores/systemActivity.js'
@@ -1,100 +1,98 @@
<template> <template>
<v-card class="fc-post-maint"> <MaintenanceTile
<CardHeading icon="mdi-post-outline" title="Post maintenance" /> icon="mdi-post-outline"
<v-card-text> title="Bare posts"
<p class="fc-muted text-body-2 mb-4"> blurb="Delete empty post shells with no images or attachments."
Remove <strong>bare posts</strong> post rows with no images (neither destructive
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 <p class="fc-muted text-body-2 mb-3">
post's only content was a duplicate that links to an earlier post. Posts Post rows with no images (neither their own nor a cross-posted duplicate)
that gained a duplicate-image link are spared. 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> </p>
<SampleNameGrid
<v-alert v-if="preview.sample_names?.length"
v-if="store.lastError" :names="preview.sample_names" class="mb-3"
type="warning" variant="tonal" density="compact" class="mb-3" />
>{{ store.lastError }}</v-alert>
<v-btn <v-btn
color="accent" variant="flat" rounded="pill" color="error" variant="flat" rounded="pill"
prepend-icon="mdi-magnify" prepend-icon="mdi-delete-sweep"
:loading="loadingPreview" :disabled="!preview.count"
class="mb-3" :loading="committing"
@click="onPreview" @click="onCommit"
>Preview bare posts</v-btn> >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"> <MaintenanceTile
<p class="text-body-2 mb-2"> icon="mdi-merge"
<strong>{{ preview.count }}</strong> bare post(s). title="Duplicate posts"
<span v-if="preview.count > 50" class="fc-muted"> blurb="Merge gallery-dl + native duplicate post records into one."
Showing first 50. destructive
</span> >
<span v-else-if="!preview.count" class="fc-muted"> <p class="fc-muted text-body-2 mb-3">
Nothing to clean up. When an artist was first downloaded by gallery-dl and later re-walked by the
</span> native ingester, the same post can exist twice (once keyed by the file's
</p> attachment id, once by the real post id). This merges each pair onto one
<SampleNameGrid canonical post (keeping the native post-id key), moving images, attachments
v-if="preview.sample_names?.length" and links onto the survivor. Images themselves are never touched.
:names="preview.sample_names" class="mb-3" </p>
/>
<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>
<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"> <div v-if="dupPreview">
Unify <strong>duplicate posts</strong> — when an artist was first <p class="text-body-2 mb-2">
downloaded by gallery-dl and later re-walked by the native ingester, the <strong>{{ dupPreview.groups }}</strong> duplicate group(s),
same post can exist twice (once keyed by the file's attachment id, once by <strong>{{ dupPreview.posts_to_merge }}</strong> redundant row(s) to merge.
the real post id). This merges each pair onto one canonical post (keeping <span v-if="dupPreview.groups > 50" class="fc-muted">Showing first 50.</span>
the native post-id key), moving images, attachments and links onto the <span v-else-if="!dupPreview.groups" class="fc-muted">No duplicates found.</span>
survivor. Images themselves are never touched.
</p> </p>
<SampleNameGrid
v-if="dupSampleNames.length"
:names="dupSampleNames" class="mb-3"
/>
<v-btn <v-btn
color="accent" variant="flat" rounded="pill" color="error" variant="flat" rounded="pill"
prepend-icon="mdi-magnify" prepend-icon="mdi-merge"
:loading="loadingDupPreview" :disabled="!dupPreview.groups"
class="mb-3" :loading="merging"
@click="onPreviewDupes" @click="onMergeDupes"
>Preview duplicate posts</v-btn> >Merge {{ dupPreview.posts_to_merge }} duplicate(s)</v-btn>
<span v-if="merged != null" class="ml-3 text-caption text-success">
<div v-if="dupPreview"> Merged {{ merged }}
<p class="text-body-2 mb-2"> </span>
<strong>{{ dupPreview.groups }}</strong> duplicate group(s), </div>
<strong>{{ dupPreview.posts_to_merge }}</strong> redundant row(s) to </MaintenanceTile>
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>
</template> </template>
<script setup> <script setup>
@@ -102,7 +100,7 @@ import { computed, ref } from 'vue'
import { useAdminStore } from '../../stores/admin.js' import { useAdminStore } from '../../stores/admin.js'
import SampleNameGrid from '../common/SampleNameGrid.vue' import SampleNameGrid from '../common/SampleNameGrid.vue'
import CardHeading from '../common/CardHeading.vue' import MaintenanceTile from '../common/MaintenanceTile.vue'
const store = useAdminStore() const store = useAdminStore()
const preview = ref(null) const preview = ref(null)
@@ -165,7 +163,3 @@ async function onMergeDupes() {
} }
} }
</script> </script>
<style scoped>
.fc-post-maint { border-radius: 8px; }
</style>
@@ -1,191 +1,201 @@
<template> <template>
<v-card class="fc-tag-maint"> <v-alert
<CardHeading icon="mdi-tag-remove" title="Tag maintenance" /> v-if="store.lastError"
<v-card-text> type="warning" variant="tonal" density="compact" class="mb-3"
<p class="fc-muted text-body-2 mb-4"> >{{ store.lastError }}</v-alert>
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 <MaintenanceTile
v-if="store.lastError" icon="mdi-tag-remove"
type="warning" variant="tonal" density="compact" class="mb-3" title="Unused tags"
>{{ store.lastError }}</v-alert> 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 <v-btn
color="accent" variant="flat" rounded="pill" color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify" prepend-icon="mdi-magnify"
:loading="loadingPreview" :loading="loadingPreview"
class="mb-3" class="mb-3"
@click="onPreview" @click="onPreview"
>Preview unused tags</v-btn> >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" />
<div v-if="preview">
<p class="text-body-2 mb-2"> <p class="text-body-2 mb-2">
<strong class="text-error">Reset content tagging.</strong> <strong>{{ preview.count }}</strong> unused tag(s).
Deletes every <code>general</code> and <code>character</code> tag and <span v-if="preview.count > 50" class="fc-muted">Showing first 50 names.</span>
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> </p>
<v-alert type="warning" variant="tonal" density="compact" class="mb-3"> <SampleNameGrid
Irreversible there's no undo except restoring a DB backup. v-if="preview.sample_names?.length"
Back one up first (Settings → Maintenance → Backup). :names="preview.sample_names" class="mb-3"
</v-alert> />
<v-btn <v-btn
color="accent" variant="flat" rounded="pill" color="error" variant="flat" rounded="pill"
prepend-icon="mdi-magnify" prepend-icon="mdi-delete-sweep"
:loading="loadingResetPreview" :disabled="!preview.count"
class="mb-3" :loading="committing"
@click="onResetPreview" @click="onCommit"
>Preview content-tag reset</v-btn> >Delete {{ preview.count }} unused tag(s)</v-btn>
</div>
</MaintenanceTile>
<div v-if="resetPreview"> <MaintenanceTile
<p class="text-body-2 mb-2"> icon="mdi-tag-off"
<strong>{{ resetPreview.count }}</strong> content tag(s) title="Legacy migration tags"
<span v-for="(n, k) in resetPreview.by_kind" :key="k" class="fc-muted"> blurb="Purge retired archive/post/artist + source:* tags."
({{ k }}: {{ n }})&nbsp; destructive
</span> >
across <strong>{{ resetPreview.applications }}</strong> image <p class="fc-muted text-body-2 mb-3">
application(s). Purge legacy IR-migration tags FC no longer uses: retired/system
</p> kinds (<code>archive</code>, <code>post</code>, <code>artist</code> e.g.
<SampleNameGrid <code>BlenderKnight:Hannah_BJ_Loops</code>) plus <code>source:*</code> tags
v-if="resetPreview.sample_names?.length" (ImageRepo's old <code>source</code> kind, migrated to <code>general</code>).
:names="resetPreview.sample_names" class="mb-3" Provenance and artists are their own systems now, so these are pure noise.
/> Removes them from every image.
<v-btn </p>
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>
<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 <div v-if="kindPreview">
and merge case/whitespace-variant duplicates. -->
<p class="text-body-2 mb-2"> <p class="text-body-2 mb-2">
<strong>Standardize tag casing.</strong> <strong>{{ kindPreview.count }}</strong> legacy tag(s).
New tags are saved Title Case, but older tags keep whatever casing they <span v-for="(n, k) in kindPreview.by_kind" :key="k" class="fc-muted">
were created with. This renames every existing tag to {{ k }}: {{ n }}&nbsp;&nbsp;
<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> </span>
</div> <span v-for="(n, p) in kindPreview.by_prefix" :key="p" class="fc-muted">
</v-card-text> {{ p }}: {{ n }}&nbsp;&nbsp;
</v-card> </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> </template>
<script setup> <script setup>
@@ -193,7 +203,7 @@ import { ref } from 'vue'
import { useAdminStore } from '../../stores/admin.js' import { useAdminStore } from '../../stores/admin.js'
import SampleNameGrid from '../common/SampleNameGrid.vue' import SampleNameGrid from '../common/SampleNameGrid.vue'
import CardHeading from '../common/CardHeading.vue' import MaintenanceTile from '../common/MaintenanceTile.vue'
const store = useAdminStore() const store = useAdminStore()
const preview = ref(null) const preview = ref(null)
@@ -287,17 +297,8 @@ async function onNormCommit() {
// confirm it's queued; the operator can re-run Preview later to verify. // confirm it's queued; the operator can re-run Preview later to verify.
await store.normalizeTags({ dryRun: false }) await store.normalizeTags({ dryRun: false })
normResult.value = 'queued' 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 { } finally {
normCommitting.value = false normCommitting.value = false
} }
} }
</script> </script>
<style scoped>
.fc-tag-maint { border-radius: 8px; }
</style>
@@ -3,54 +3,57 @@
duration + aspect (the same clip re-encoded / pulled from multiple duration + aspect (the same clip re-encoded / pulled from multiple
sources). Preview first, then apply (destructive: removes the redundant sources). Preview first, then apply (destructive: removes the redundant
copies, keeping the highest-resolution one). --> copies, keeping the highest-resolution one). -->
<v-card> <MaintenanceTile
<v-card-title>Deduplicate videos</v-card-title> icon="mdi-content-duplicate"
<v-card-text> title="Deduplicate videos"
<p class="text-body-2 mb-3"> blurb="Collapse re-encoded duplicate videos, keeping the best copy."
Finds videos of the same artist that are the same content across destructive
re-encodes (matching duration + aspect ratio) and keeps the :open="applying || previewing"
highest-resolution copy. <strong>Preview</strong> first to see what would >
be removed; <strong>Apply</strong> re-points each post to the kept copy, <p class="text-body-2 mb-3">
then deletes the redundant videos and their files. The first run also Finds videos of the same artist that are the same content across
computes durations for older videos, so it may take a while. re-encodes (matching duration + aspect ratio) and keeps the
</p> 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;"> <div class="d-flex align-center flex-wrap" style="gap: 12px;">
<v-btn <v-btn
color="primary" variant="tonal" rounded="pill" color="primary" variant="tonal" rounded="pill"
:loading="previewing" :disabled="applying" @click="preview" :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"
> >
<span v-if="applied"> <v-icon start>mdi-magnify</v-icon> Preview
Removed {{ summary.deleted }} redundant video(s) across </v-btn>
{{ summary.groups }} group(s); re-pointed {{ summary.relinked_posts }} <v-btn
post link(s); reclaimed {{ humanBytes(summary.reclaim_bytes) }}. color="error" rounded="pill"
</span> :loading="applying"
<span v-else-if="summary.redundant > 0"> :disabled="previewing || !canApply"
{{ summary.redundant }} redundant video(s) across {{ summary.groups }} @click="confirmOpen = true"
group(s) {{ humanBytes(summary.reclaim_bytes) }} reclaimable. Click >
<strong>Apply</strong> to remove them (keeping the best copy). <v-icon start>mdi-content-duplicate</v-icon> Apply
</span> </v-btn>
<span v-else>No duplicate videos found.</span> </div>
</v-alert>
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" /> <v-alert
</v-card-text> 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-dialog v-model="confirmOpen" max-width="440">
<v-card> <v-card>
@@ -68,13 +71,14 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
</v-card> </MaintenanceTile>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js' import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue' import QueueStatusBar from './QueueStatusBar.vue'
const confirmOpen = ref(false) const confirmOpen = ref(false)
+60 -15
View File
@@ -1,16 +1,44 @@
<template> <template>
<div class="fc-cleanup"> <div class="fc-cleanup">
<p class="fc-muted text-body-2 mb-4"> <p class="fc-muted text-body-2 mb-5">
Retroactive enforcement of import-filter rules. Each card scans the Retroactive enforcement and cleanup on the existing library each tool
existing library for content that the current import filters would previews before it acts, and destructive ones need a typed-token confirm.
now exclude. Destructive typed-token confirmation required. Click a tile to open its controls.
</p> </p>
<MinDimensionCard class="mb-4" /> <section class="fc-section">
<TransparencyAuditCard class="mb-4" /> <h3 class="fc-section__title">Import-filter audits</h3>
<SingleColorAuditCard class="mb-4" /> <p class="fc-section__hint">
<PostMaintenanceCard class="mb-4" /> Scan for content the current import filters would now exclude.
<TagMaintenanceCard /> </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> </div>
</template> </template>
@@ -19,14 +47,31 @@ import MinDimensionCard from '../components/cleanup/MinDimensionCard.vue'
import TransparencyAuditCard from '../components/cleanup/TransparencyAuditCard.vue' import TransparencyAuditCard from '../components/cleanup/TransparencyAuditCard.vue'
import SingleColorAuditCard from '../components/cleanup/SingleColorAuditCard.vue' import SingleColorAuditCard from '../components/cleanup/SingleColorAuditCard.vue'
import PostMaintenanceCard from '../components/settings/PostMaintenanceCard.vue' import PostMaintenanceCard from '../components/settings/PostMaintenanceCard.vue'
// Reuse existing TagMaintenanceCard (FC-3k) as-is — it already handles import VideoDedupCard from '../components/settings/VideoDedupCard.vue'
// preview + commit of prune-unused-tags via the admin store. Operator import GatedPurgeCard from '../components/settings/GatedPurgeCard.vue'
// 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 TagMaintenanceCard from '../components/settings/TagMaintenanceCard.vue' import TagMaintenanceCard from '../components/settings/TagMaintenanceCard.vue'
</script> </script>
<style scoped> <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> </style>