diff --git a/frontend/src/components/settings/GatedPurgeCard.vue b/frontend/src/components/settings/GatedPurgeCard.vue
index a08d528..64d2a3b 100644
--- a/frontend/src/components/settings/GatedPurgeCard.vue
+++ b/frontend/src/components/settings/GatedPurgeCard.vue
@@ -73,7 +73,7 @@
-
+
@@ -98,16 +98,19 @@
diff --git a/frontend/src/components/settings/VideoDedupCard.vue b/frontend/src/components/settings/VideoDedupCard.vue
index 3b3571b..3ff9cd4 100644
--- a/frontend/src/components/settings/VideoDedupCard.vue
+++ b/frontend/src/components/settings/VideoDedupCard.vue
@@ -49,7 +49,7 @@
No duplicate videos found.
-
+
@@ -74,16 +74,19 @@
diff --git a/frontend/src/composables/useMaintenanceTask.js b/frontend/src/composables/useMaintenanceTask.js
new file mode 100644
index 0000000..acbff0e
--- /dev/null
+++ b/frontend/src/composables/useMaintenanceTask.js
@@ -0,0 +1,118 @@
+// Drives a preview/apply maintenance card whose work runs as a long Celery task
+// (re-walks, full-library probes) and whose result must survive the operator
+// navigating away or reloading the page — "being able to navigate away from a
+// maintenance task is very important" (operator, 2026-06-16).
+//
+// The card-specific bits (canApply/summaryType/humanBytes, the confirm dialog,
+// the result copy) stay in the card; this composable owns the shared task
+// lifecycle: dispatch → persist the task id → poll → resurface on mount.
+//
+// How resurfacing works: on dispatch we stash {taskId, mode, startedAt} in
+// localStorage. On mount we re-read it and resume polling the SAME task, so a
+// run that finished while the operator was away shows its result the moment
+// they come back, and one still in flight re-attaches its spinner. The Celery
+// result backend retains the summary well under result_expires, so the re-fetch
+// succeeds. localStorage is cleared once the result is observed. (localStorage
+// is safe on the plain-HTTP origin — rule 95.)
+
+import { onMounted, ref } from 'vue'
+
+import { useApi } from './useApi.js'
+import { toast } from '../utils/toast.js'
+
+// Don't resume a task whose dispatch is older than this — past the longest
+// plausible run (the gated purge's hard time_limit is 40min) it's almost
+// certainly finished-and-expired or dead, and a stale poll would just spin.
+const STALE_RESUME_MS = 3 * 60 * 60 * 1000 // 3h
+
+export function useMaintenanceTask ({ endpoint, storageKey, appliedToast, maxPolls = 300, pollMs = 2000 }) {
+ const api = useApi()
+ const previewing = ref(false)
+ const applying = ref(false)
+ const summary = ref(null)
+ const applied = ref(false)
+
+ function persist (taskId, mode, startedAt) {
+ try {
+ localStorage.setItem(storageKey, JSON.stringify({ taskId, mode, startedAt }))
+ } catch { /* private mode / quota — degrade to non-resurfacing, no crash */ }
+ }
+ function clearPersist () {
+ try { localStorage.removeItem(storageKey) } catch { /* ignore */ }
+ }
+
+ // Poll-then-wait: check immediately first so a resumed-but-already-finished
+ // task resolves on the first tick instead of after a needless pollMs delay.
+ async function pollResult (taskId) {
+ for (let i = 0; i < maxPolls; i++) {
+ const r = await api.get(`/api/admin/maintenance/task-result/${taskId}`)
+ if (r.ready) {
+ clearPersist()
+ if (!r.successful) throw new Error('the task failed — check the worker logs')
+ return r.result
+ }
+ await new Promise(res => setTimeout(res, pollMs))
+ }
+ throw new Error('timed out waiting for the task — check the task dashboard')
+ }
+
+ async function run (mode) {
+ const { task_id: taskId } = await api.post(endpoint, { body: { dry_run: mode === 'preview' } })
+ persist(taskId, mode, Date.now())
+ return pollResult(taskId)
+ }
+
+ async function preview () {
+ previewing.value = true
+ applied.value = false
+ summary.value = null
+ try {
+ summary.value = await run('preview')
+ } catch (e) {
+ toast({ text: e?.body?.detail || e?.message || 'Preview failed', type: 'error' })
+ } finally {
+ previewing.value = false
+ }
+ }
+
+ async function apply () {
+ applying.value = true
+ try {
+ summary.value = await run('apply')
+ applied.value = true
+ toast({ text: appliedToast, type: 'success' })
+ } catch (e) {
+ toast({ text: e?.body?.detail || e?.message || 'Apply failed', type: 'error' })
+ } finally {
+ applying.value = false
+ }
+ }
+
+ // Re-attach to a task left in flight (or finished) while the card was unmounted.
+ function resume () {
+ let saved = null
+ try { saved = JSON.parse(localStorage.getItem(storageKey) || 'null') } catch { saved = null }
+ if (!saved?.taskId) return
+ if (saved.startedAt && (Date.now() - saved.startedAt) > STALE_RESUME_MS) {
+ clearPersist()
+ return
+ }
+ const isApply = saved.mode === 'apply'
+ if (isApply) applying.value = true
+ else previewing.value = true
+ pollResult(saved.taskId)
+ .then(result => {
+ summary.value = result
+ if (isApply) {
+ applied.value = true
+ toast({ text: appliedToast, type: 'success' })
+ }
+ })
+ .catch(() => { clearPersist() }) // expired/gone — drop quietly, no scary toast on load
+ .finally(() => { previewing.value = false; applying.value = false })
+ }
+
+ onMounted(resume)
+
+ return { previewing, applying, summary, applied, preview, apply }
+}