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,10 +1,12 @@
<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"
blurb="Delete images below the import min-width / min-height threshold."
destructive
>
<p class="fc-muted text-body-2 mb-3"> <p class="fc-muted text-body-2 mb-3">
Find and delete images smaller than the threshold. Mirrors the Mirrors the import-time <code>min_width</code> / <code>min_height</code>
import-time <code>min_width</code> / <code>min_height</code>
filter, applied retroactively to the existing library. filter, applied retroactively to the existing library.
</p> </p>
@@ -43,7 +45,6 @@
prepend-icon="mdi-delete" prepend-icon="mdi-delete"
@click="onDeleteClick" @click="onDeleteClick"
>Delete {{ preview.count }} matching...</v-btn> >Delete {{ preview.count }} matching...</v-btn>
</v-card-text>
<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,7 +1,11 @@
<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"
blurb="Find & delete near-solid / placeholder images."
destructive
:open="audit?.status === 'running'"
>
<p class="fc-muted text-body-2 mb-3"> <p class="fc-muted text-body-2 mb-3">
Scan library for images dominated by one color within the Scan library for images dominated by one color within the
tolerance. Catches placeholder / solid-fill / error-page images tolerance. Catches placeholder / solid-fill / error-page images
@@ -73,7 +77,6 @@
v-if="audit && audit.status === 'applied'" v-if="audit && audit.status === 'applied'"
type="success" variant="tonal" density="compact" class="mt-3" type="success" variant="tonal" density="compact" class="mt-3"
>Applied matched images deleted.</v-alert> >Applied matched images deleted.</v-alert>
</v-card-text>
<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,7 +1,11 @@
<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"
blurb="Find & delete images that are mostly transparent."
destructive
:open="audit?.status === 'running'"
>
<p class="fc-muted text-body-2 mb-3"> <p class="fc-muted text-body-2 mb-3">
Scan library for images whose transparent-pixel fraction exceeds Scan library for images whose transparent-pixel fraction exceeds
the threshold. Animated WebPs / GIFs are skipped (the import-side the threshold. Animated WebPs / GIFs are skipped (the import-side
@@ -60,7 +64,6 @@
v-if="audit && audit.status === 'applied'" v-if="audit && audit.status === 'applied'"
type="success" variant="tonal" density="compact" class="mt-3" type="success" variant="tonal" density="compact" class="mt-3"
>Applied matched images deleted.</v-alert> >Applied matched images deleted.</v-alert>
</v-card-text>
<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,13 +1,15 @@
<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> </p>
<v-alert <v-alert
@@ -26,12 +28,8 @@
<div v-if="preview"> <div v-if="preview">
<p class="text-body-2 mb-2"> <p class="text-body-2 mb-2">
<strong>{{ preview.count }}</strong> bare post(s). <strong>{{ preview.count }}</strong> bare post(s).
<span v-if="preview.count > 50" class="fc-muted"> <span v-if="preview.count > 50" class="fc-muted">Showing first 50.</span>
Showing first 50. <span v-else-if="!preview.count" class="fc-muted">Nothing to clean up.</span>
</span>
<span v-else-if="!preview.count" class="fc-muted">
Nothing to clean up.
</span>
</p> </p>
<SampleNameGrid <SampleNameGrid
v-if="preview.sample_names?.length" v-if="preview.sample_names?.length"
@@ -44,21 +42,24 @@
:loading="committing" :loading="committing"
@click="onCommit" @click="onCommit"
>Delete {{ preview.count }} bare post(s)</v-btn> >Delete {{ preview.count }} bare post(s)</v-btn>
<span <span v-if="deleted != null" class="ml-3 text-caption text-success">
v-if="deleted != null" Deleted {{ deleted }} ✓
class="ml-3 text-caption text-success" </span>
>Deleted {{ deleted }} ✓</span>
</div> </div>
</MaintenanceTile>
<v-divider class="my-5" /> <MaintenanceTile
icon="mdi-merge"
<p class="fc-muted text-body-2 mb-4"> title="Duplicate posts"
Unify <strong>duplicate posts</strong> — when an artist was first blurb="Merge gallery-dl + native duplicate post records into one."
downloaded by gallery-dl and later re-walked by the native ingester, the destructive
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 <p class="fc-muted text-body-2 mb-3">
the native post-id key), moving images, attachments and links onto the When an artist was first downloaded by gallery-dl and later re-walked by the
survivor. Images themselves are never touched. 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> </p>
<v-btn <v-btn
@@ -72,8 +73,7 @@
<div v-if="dupPreview"> <div v-if="dupPreview">
<p class="text-body-2 mb-2"> <p class="text-body-2 mb-2">
<strong>{{ dupPreview.groups }}</strong> duplicate group(s), <strong>{{ dupPreview.groups }}</strong> duplicate group(s),
<strong>{{ dupPreview.posts_to_merge }}</strong> redundant row(s) to <strong>{{ dupPreview.posts_to_merge }}</strong> redundant row(s) to merge.
merge.
<span v-if="dupPreview.groups > 50" class="fc-muted">Showing first 50.</span> <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> <span v-else-if="!dupPreview.groups" class="fc-muted">No duplicates found.</span>
</p> </p>
@@ -88,13 +88,11 @@
:loading="merging" :loading="merging"
@click="onMergeDupes" @click="onMergeDupes"
>Merge {{ dupPreview.posts_to_merge }} duplicate(s)</v-btn> >Merge {{ dupPreview.posts_to_merge }} duplicate(s)</v-btn>
<span <span v-if="merged != null" class="ml-3 text-caption text-success">
v-if="merged != null" Merged {{ merged }}
class="ml-3 text-caption text-success" </span>
>Merged {{ merged }} </span>
</div> </div>
</v-card-text> </MaintenanceTile>
</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,18 +1,20 @@
<template> <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-alert
v-if="store.lastError" v-if="store.lastError"
type="warning" variant="tonal" density="compact" class="mb-3" type="warning" variant="tonal" density="compact" class="mb-3"
>{{ store.lastError }}</v-alert> >{{ 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 <v-btn
color="accent" variant="flat" rounded="pill" color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify" prepend-icon="mdi-magnify"
@@ -24,9 +26,7 @@
<div v-if="preview"> <div v-if="preview">
<p class="text-body-2 mb-2"> <p class="text-body-2 mb-2">
<strong>{{ preview.count }}</strong> unused tag(s). <strong>{{ preview.count }}</strong> unused tag(s).
<span v-if="preview.count > 50" class="fc-muted"> <span v-if="preview.count > 50" class="fc-muted">Showing first 50 names.</span>
Showing first 50 names.
</span>
</p> </p>
<SampleNameGrid <SampleNameGrid
v-if="preview.sample_names?.length" v-if="preview.sample_names?.length"
@@ -40,17 +40,20 @@
@click="onCommit" @click="onCommit"
>Delete {{ preview.count }} unused tag(s)</v-btn> >Delete {{ preview.count }} unused tag(s)</v-btn>
</div> </div>
</MaintenanceTile>
<v-divider class="my-5" /> <MaintenanceTile
icon="mdi-tag-off"
<p class="fc-muted text-body-2 mb-4"> 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 Purge legacy IR-migration tags FC no longer uses: retired/system
kinds (<code>archive</code>, <code>post</code>, kinds (<code>archive</code>, <code>post</code>, <code>artist</code> e.g.
<code>artist</code> e.g. <code>BlenderKnight:Hannah_BJ_Loops</code>) plus <code>source:*</code> tags
<code>BlenderKnight:Hannah_BJ_Loops</code>) plus (ImageRepo's old <code>source</code> kind, migrated to <code>general</code>).
<code>source:*</code> tags (ImageRepo's old <code>source</code> Provenance and artists are their own systems now, so these are pure noise.
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. Removes them from every image.
</p> </p>
@@ -84,11 +87,15 @@
@click="onKindCommit" @click="onKindCommit"
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn> >Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
</div> </div>
</MaintenanceTile>
<v-divider class="my-5" /> <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"> <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 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 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 auto-suggest. <strong>Fandoms and series (with their page order) are
@@ -130,19 +137,23 @@
>Delete {{ resetPreview.count }} content tag(s) + >Delete {{ resetPreview.count }} content tag(s) +
{{ resetPreview.applications }} application(s)</v-btn> {{ resetPreview.applications }} application(s)</v-btn>
</div> </div>
</MaintenanceTile>
<v-divider class="my-5" /> <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 <!-- #714: standardize existing tags to the canonical Title-Case form
and merge case/whitespace-variant duplicates. --> 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>
New tags are saved Title Case, but older tags keep whatever casing they New tags are saved Title Case, but older tags keep whatever casing they
were created with. This renames every existing tag to were created with. This renames every existing tag to <code>Title Case</code>
<code>Title Case</code> (collapsing extra spaces) and (collapsing extra spaces) and <strong>merges</strong> tags that differ only
<strong>merges</strong> tags that differ only by case or spacing by case or spacing (e.g. <code>hatsune miku</code> +
(e.g. <code>hatsune miku</code> + <code>Hatsune&nbsp;&nbsp;Miku</code>) <code>Hatsune&nbsp;&nbsp;Miku</code>) into one — repointing their images,
into one — repointing their images, allowlist entries and series pages. allowlist entries and series pages.
</p> </p>
<v-alert type="warning" variant="tonal" density="compact" class="mb-3"> <v-alert type="warning" variant="tonal" density="compact" class="mb-3">
The merges are irreversible — back up first The merges are irreversible — back up first
@@ -184,8 +195,7 @@
processes in chunks, so re-run “Preview” later to confirm it's all done. processes in chunks, so re-run “Preview” later to confirm it's all done.
</span> </span>
</div> </div>
</v-card-text> </MaintenanceTile>
</v-card>
</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,9 +3,13 @@
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"
blurb="Collapse re-encoded duplicate videos, keeping the best copy."
destructive
:open="applying || previewing"
>
<p class="text-body-2 mb-3"> <p class="text-body-2 mb-3">
Finds videos of the same artist that are the same content across Finds videos of the same artist that are the same content across
re-encodes (matching duration + aspect ratio) and keeps the re-encodes (matching duration + aspect ratio) and keeps the
@@ -50,7 +54,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="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)
+59 -14
View File
@@ -1,17 +1,45 @@
<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.
</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 /> <TagMaintenanceCard />
</div> </div>
</section>
</div>
</template> </template>
<script setup> <script setup>
@@ -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>