9deebfa133
The icon+title v-card-title heading (d-flex align-center + gap + <v-icon size=small> + <span>) was hand-rolled identically in 13 cards/dialogs (15 heading instances). Consolidate to <CardHeading icon title> (components/common) with an iconColor prop (error headings) and a default slot for trailing content (spacer+actions, inline status chip). Adopted everywhere the pattern appears — all-or-nothing per the hardened DRY process. Over-DRY guard: plain text-only <v-card-title> one-liners are NOT this pattern and stay; DownloadDetailModal leads with a status CHIP (not an icon), a different concept, left alone. §8b: the only remaining d-flex align-center v-card-title is that intentional variant. Catalog updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
175 lines
5.2 KiB
Vue
175 lines
5.2 KiB
Vue
<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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
<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 === '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>
|
||
</v-card-text>
|
||
|
||
<DestructiveConfirmModal
|
||
v-if="audit"
|
||
v-model="showModal"
|
||
action="delete"
|
||
kind="audit"
|
||
:run-id="audit.id"
|
||
tier="C"
|
||
:projected-counts="projectedCounts"
|
||
description="Permanently deletes images matched by the transparency scan."
|
||
@confirm="onConfirmedApply"
|
||
/>
|
||
</v-card>
|
||
</template>
|
||
|
||
<script setup>
|
||
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 { useCleanupStore } from '../../stores/cleanup.js'
|
||
|
||
const store = useCleanupStore()
|
||
const threshold = ref(0.9)
|
||
const audit = ref(null)
|
||
const busy = ref(false)
|
||
const showModal = ref(false)
|
||
const projectedCounts = ref({})
|
||
let pollTimer = null
|
||
|
||
onMounted(async () => {
|
||
await store.loadDefaults()
|
||
threshold.value = store.defaults.transparency_threshold
|
||
// Reconnect to this rule's latest run so a scan started before navigating
|
||
// away keeps showing progress / its result on return (the scan itself runs
|
||
// backend-side regardless).
|
||
try {
|
||
const latest = await store.latestAuditForRule('transparency')
|
||
if (latest) {
|
||
audit.value = latest
|
||
if (latest.status === 'running') startPoll(latest.id)
|
||
}
|
||
} catch { /* non-fatal — card still works for a fresh scan */ }
|
||
})
|
||
|
||
onUnmounted(() => stopPoll())
|
||
|
||
function startPoll(id) {
|
||
stopPoll()
|
||
pollTimer = setInterval(async () => {
|
||
try {
|
||
const fresh = await store.getAudit(id)
|
||
audit.value = fresh
|
||
if (fresh.status !== 'running') stopPoll()
|
||
} catch (e) {
|
||
stopPoll()
|
||
toast({ text: `Audit poll failed: ${e.message}`, type: 'error' })
|
||
}
|
||
}, 5000)
|
||
}
|
||
|
||
function stopPoll() {
|
||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||
}
|
||
|
||
async function onStart() {
|
||
busy.value = true
|
||
try {
|
||
const res = await store.startAudit('transparency', { threshold: threshold.value })
|
||
audit.value = await store.getAudit(res.audit_id)
|
||
startPoll(res.audit_id)
|
||
} catch (e) {
|
||
toast({ text: `Scan start failed: ${e.message}`, type: 'error' })
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
async function onCancel() {
|
||
if (!audit.value) return
|
||
try {
|
||
await store.cancelAudit(audit.value.id)
|
||
audit.value = await store.getAudit(audit.value.id)
|
||
stopPoll()
|
||
} catch (e) {
|
||
toast({ text: `Cancel failed: ${e.message}`, type: 'error' })
|
||
}
|
||
}
|
||
|
||
function onApplyClick() {
|
||
projectedCounts.value = { 'Images to delete': audit.value.matched_count }
|
||
showModal.value = true
|
||
}
|
||
|
||
async function onConfirmedApply(token) {
|
||
try {
|
||
const res = await store.applyAudit(audit.value.id, token)
|
||
toast({
|
||
text: `Deleted ${res.deleted} image(s)`, type: 'success',
|
||
})
|
||
audit.value = await store.getAudit(audit.value.id)
|
||
} catch (e) {
|
||
toast({ text: `Apply failed: ${e.message}`, type: 'error' })
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.fc-clean-card { border-radius: 8px; }
|
||
</style>
|