3b435dc0ba
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>
115 lines
4.3 KiB
Vue
115 lines
4.3 KiB
Vue
<template>
|
||
<!-- #871: Tier-1 video dedup — collapse same-artist videos that match on
|
||
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). -->
|
||
<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"
|
||
>
|
||
<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>
|
||
<v-card-title>Remove duplicate videos?</v-card-title>
|
||
<v-card-text class="text-body-2">
|
||
This permanently deletes
|
||
<strong>{{ summary?.redundant ?? 0 }}</strong> redundant video file(s),
|
||
keeping the highest-resolution copy in each group. Posts are re-pointed
|
||
to the kept copy first, so nothing disappears from a post.
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="confirmOpen = false">Cancel</v-btn>
|
||
<v-btn color="error" @click="apply">Remove duplicates</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</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)
|
||
|
||
// Long task (first run re-probes every NULL-duration video) — the lifecycle +
|
||
// resurface-after-navigation live in the shared composable. 150 polls × 2s ≈ 5m.
|
||
const { previewing, applying, summary, applied, preview, apply: applyTask } = useMaintenanceTask({
|
||
endpoint: '/api/admin/maintenance/dedup-videos',
|
||
storageKey: 'fc.maint.dedupVideos',
|
||
appliedToast: 'Duplicate videos removed',
|
||
maxPolls: 150,
|
||
})
|
||
|
||
const canApply = computed(() => !!summary.value && !applied.value && summary.value.redundant > 0)
|
||
const summaryType = computed(() => {
|
||
if (applied.value) return 'success'
|
||
return summary.value && summary.value.redundant > 0 ? 'info' : 'success'
|
||
})
|
||
|
||
function humanBytes (n) {
|
||
const b = Number(n || 0)
|
||
if (b >= 1 << 30) return (b / (1 << 30)).toFixed(1) + ' GB'
|
||
if (b >= 1 << 20) return (b / (1 << 20)).toFixed(1) + ' MB'
|
||
if (b >= 1 << 10) return (b / (1 << 10)).toFixed(1) + ' KB'
|
||
return b + ' B'
|
||
}
|
||
|
||
// The confirm dialog gates the destructive apply; close it, then run.
|
||
function apply () {
|
||
confirmOpen.value = false
|
||
applyTask()
|
||
}
|
||
</script>
|