feat(settings): tidy Cleanup tab into sectioned compact tiles (pass 1)
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:
@@ -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 (0–1)"
|
||||
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 (0–441)"
|
||||
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 (0–1)"
|
||||
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 (0–441)"
|
||||
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 (0–1)"
|
||||
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 (0–1)"
|
||||
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 }}
|
||||
</span>
|
||||
<span v-for="(n, p) in kindPreview.by_prefix" :key="p" class="fc-muted">
|
||||
{{ p }}: {{ n }}
|
||||
</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 }})
|
||||
</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 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 }}
|
||||
</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<span v-for="(n, p) in kindPreview.by_prefix" :key="p" class="fc-muted">
|
||||
{{ p }}: {{ n }}
|
||||
</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 }})
|
||||
</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 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)
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user