feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button)

This commit is contained in:
fsylvain committed 2026-10-05 23:57:50 +02:00
1 parent 1a55a39425
commit 2303b12377
26 files changed
+3856 -83

No files matched your search

+4 -1
View File
@@ -23,6 +23,7 @@ const props = withDefaults(
stickyFooter?: boolean
fullBleed?: boolean
compactPadding?: boolean
bleedOfflineBanner?: boolean
renderWhileChecking?: boolean
offlineMessage?: string
offlineIcon?: string
@@ -36,6 +37,7 @@ const props = withDefaults(
offlineIcon: undefined,
fullBleed: false,
compactPadding: false,
bleedOfflineBanner: false,
renderWhileChecking: false,
},
)
@@ -301,7 +303,8 @@ async function onLogout() {
<OfflineBanner
:message="offlineMessage"
:icon="offlineIcon"
:bleed="compactPadding"
:bleed="compactPadding || bleedOfflineBanner"
:roomy="!compactPadding && bleedOfflineBanner"
/>
<AppBoot v-if="showBoot" />
+388
View File
@@ -0,0 +1,388 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useOnline } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { useLocalizedToast } from '@/i18n/presentation'
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
import { useMediaCleanupSettings } from '@/composables/useMediaCleanupSettings'
import type { PurgeResult } from '@/composables/useMediaPurge'
import {
DEFAULT_INTERVAL_DAYS,
REVIEW_DUPLICATES_PATH,
lastPurgeLine,
nextPurgeLine,
parseIntervalDays,
purgedToast,
} from '@/lib/mediaPurge'
import { useSessionStore } from '@/stores/session'
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
const READ_ONLY_COPY = 'Only owners and admins can change this.'
const SKELETON_BAR = 'rounded-none bg-accented'
const OUTLINE_BUTTON =
'h-11 justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:w-full md:h-8'
const session = useSessionStore()
const toast = useLocalizedToast()
const router = useRouter()
const online = useOnline()
const isAdmin = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const readOnly = computed(() => !isAdmin.value)
const offline = computed(() => !online.value)
const cleanup = useMediaCleanupSettings(() => {
session.refresh()
})
const { settings, loadState, showSkeleton, saveStatus, saveFailure } = cleanup
const enabled = ref(false)
const daysText = ref(String(DEFAULT_INTERVAL_DAYS))
const committedDays = ref(DEFAULT_INTERVAL_DAYS)
const daysInvalid = ref(false)
const daysFocused = ref(false)
const purgeOpen = ref(false)
const purgeButton = ref<{ $el: HTMLElement } | null>(null)
const loading = computed(() => !readOnly.value && loadState.value === 'loading')
const skeletonVisible = computed(() => loading.value && showSkeleton.value)
const loadFailed = computed(() => !readOnly.value && loadState.value === 'error')
const showBody = computed(() => readOnly.value || loadState.value === 'ready')
const controlsDisabled = computed(() => readOnly.value || offline.value)
const switchChecked = computed(() => !readOnly.value && enabled.value)
const showEvery = computed(() => readOnly.value || enabled.value)
const lastLine = computed(() =>
readOnly.value ? lastPurgeLine(null) : lastPurgeLine(settings.value),
)
const nextIso = computed(() =>
!readOnly.value && enabled.value ? (settings.value?.next_purge_at ?? null) : null,
)
const daysError = computed(() => (daysInvalid.value ? t(INVALID_DAYS_COPY) : ''))
const daysSaveError = computed(() => (saveFailure.value === 'days' ? t(SAVE_FAILED_COPY) : ''))
const switchSaveError = computed(() => (saveFailure.value === 'enabled' ? t(SAVE_FAILED_COPY) : ''))
const fieldError = computed(() => daysError.value || daysSaveError.value)
watch(settings, (next) => {
if (!next) {
return
}
enabled.value = next.auto_purge_enabled
committedDays.value = next.auto_purge_interval_days
if (!daysInvalid.value && !daysFocused.value) {
daysText.value = String(next.auto_purge_interval_days)
}
})
watch(
isAdmin,
(admin) => {
if (admin) {
cleanup.load()
}
},
{ immediate: true },
)
async function toggle(value: boolean) {
if (controlsDisabled.value) {
return
}
const previous = enabled.value
enabled.value = value
const saved = await cleanup.save({ auto_purge_enabled: value }, 'enabled')
if (!saved) {
enabled.value = previous
}
}
async function commitDays() {
if (controlsDisabled.value) {
return
}
const days = parseIntervalDays(daysText.value)
if (days === null) {
daysInvalid.value = true
return
}
daysInvalid.value = false
if (days === committedDays.value) {
daysText.value = String(days)
return
}
const previous = committedDays.value
committedDays.value = days
const saved = await cleanup.save({ auto_purge_interval_days: days }, 'days')
if (!saved) {
committedDays.value = previous
daysText.value = String(previous)
}
}
function onDaysBlur() {
daysFocused.value = false
commitDays()
}
function openPurge() {
if (!offline.value) {
purgeOpen.value = true
}
}
async function closePurge() {
purgeOpen.value = false
await nextTick()
purgeButton.value?.$el?.focus()
}
function onPurged(result: PurgeResult) {
toast.add(purgedToast(result))
cleanup.refresh()
}
function reviewDuplicates() {
purgeOpen.value = false
router.push(REVIEW_DUPLICATES_PATH)
}
</script>
<template>
<section
aria-labelledby="media-cleanup-h"
data-testid="media-cleanup-card"
:aria-busy="skeletonVisible ? 'true' : undefined"
class="flex flex-col gap-4 border border-default bg-default p-4 lg:p-5"
>
<template v-if="skeletonVisible">
<USkeleton :class="[SKELETON_BAR, 'h-4 w-35']" />
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-9/10']" />
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-11/20']" />
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1.5">
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-30']" />
<USkeleton :class="[SKELETON_BAR, 'h-2.75 w-45']" />
</div>
<USkeleton :class="[SKELETON_BAR, 'h-5 w-9']" />
</div>
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-3/5']" />
<USeparator />
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1.5">
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-25']" />
<USkeleton :class="[SKELETON_BAR, 'h-2.75 w-55']" />
</div>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-merge"
disabled
:class="OUTLINE_BUTTON"
:ui="{ leadingIcon: 'size-4' }"
>
{{ t('Purge duplicates') }}
</UButton>
</div>
</template>
<template v-else>
<div class="flex items-start gap-3">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<h2 id="media-cleanup-h" class="text-lg font-semibold text-highlighted">
{{ t('Media cleanup') }}
</h2>
<p class="text-pretty text-muted">
{{
t(
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.',
)
}}
</p>
</div>
<span
aria-live="polite"
data-testid="cleanup-save-status"
class="flex h-6 shrink-0 items-center"
>
<span
v-if="saveStatus === 'saving'"
class="inline-flex items-center gap-1.5 text-xs text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
{{ t('Saving…') }}
</span>
<span
v-else-if="saveStatus === 'saved'"
class="inline-flex items-center gap-1.5 text-xs text-success"
>
<UIcon name="i-lucide-check" class="size-3.5" />
{{ t('Saved') }}
</span>
</span>
</div>
<UAlert
v-if="loadFailed"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:description="t(UNREACHABLE_COPY)"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="cleanup.load"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<template v-else-if="showBody">
<UAlert
v-if="readOnly"
role="status"
color="info"
variant="soft"
:close="false"
icon="i-lucide-info"
:description="t(READ_ONLY_COPY)"
/>
<div
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
>
<div class="flex min-w-0 flex-col gap-0.5">
<span id="auto-purge-label" class="font-medium text-highlighted">{{
t('Automatic purge')
}}</span>
<span class="text-xs/5 text-muted">{{
t('Runs in the background. Off by default.')
}}</span>
<span
v-if="switchSaveError"
role="alert"
data-testid="cleanup-switch-error"
class="flex items-start gap-1.5 text-xs/5 text-error"
>
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
{{ switchSaveError }}
</span>
</div>
<div class="flex h-11 w-11 shrink-0 items-center justify-center md:h-6 md:w-9">
<USwitch
:model-value="switchChecked"
:disabled="controlsDisabled"
aria-labelledby="auto-purge-label"
:ui="{ base: 'data-[disabled]:opacity-40' }"
@update:model-value="toggle"
/>
</div>
</div>
<div
v-if="showEvery"
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
>
<div class="flex min-w-0 flex-col gap-0.5">
<label for="auto-purge-days" class="font-medium text-highlighted">{{
t('Every')
}}</label>
<span class="text-xs/5 text-muted">{{ t('Between 1 and 365 days.') }}</span>
</div>
<div class="flex flex-col gap-1.5 md:items-end">
<UInput
id="auto-purge-days"
v-model="daysText"
type="text"
inputmode="numeric"
:disabled="controlsDisabled"
:highlight="fieldError !== ''"
:color="fieldError ? 'error' : 'neutral'"
:aria-invalid="fieldError ? 'true' : undefined"
class="w-full md:w-35"
:ui="{
base: 'h-11 pe-14 font-mono text-lg disabled:bg-default disabled:opacity-40 disabled:ring-(--ui-border-accented) md:h-10 md:text-sm',
trailing: 'pe-3',
}"
@focus="daysFocused = true"
@blur="onDaysBlur"
@keydown.enter="commitDays"
>
<template #trailing>
<span class="font-mono text-sm text-muted">{{ t('days') }}</span>
</template>
</UInput>
<span
v-if="fieldError"
role="alert"
data-testid="cleanup-days-error"
class="flex items-start gap-1.5 text-xs/5 text-error md:max-w-75 md:text-right"
>
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
{{ fieldError }}
</span>
</div>
</div>
<div class="flex flex-col gap-1 text-toned">
<span data-testid="cleanup-last">{{ lastLine }}</span>
<span v-if="nextIso" data-testid="cleanup-next">{{
nextPurgeLine(nextIso)
}}</span>
</div>
<template v-if="!readOnly">
<USeparator />
<div
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="font-medium text-highlighted">{{ t('Purge now') }}</span>
<span class="text-xs/5 text-muted">{{
t(
'Merge every group of duplicates that has an image in use, right away.',
)
}}</span>
</div>
<div class="flex md:shrink-0">
<UButton
ref="purgeButton"
color="neutral"
variant="outline"
icon="i-lucide-merge"
:disabled="offline"
:class="OUTLINE_BUTTON"
:ui="{ leadingIcon: 'size-4' }"
@click="openPurge"
>
{{ t('Purge duplicates') }}
</UButton>
</div>
</div>
</template>
</template>
</template>
<PurgeDuplicatesDialog
v-if="purgeOpen"
@close="closePurge"
@purged="onPurged"
@partial="cleanup.refresh"
@review="reviewDuplicates"
/>
</section>
</template>
+27 -5
View File
@@ -40,16 +40,22 @@ const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
},
]
const props = defineProps<{
controls: MediaControls
counts: MediaCounts | null
}>()
const props = withDefaults(
defineProps<{
controls: MediaControls
counts: MediaCounts | null
showPurge?: boolean
purgeDisabled?: boolean
}>(),
{ showPurge: false, purgeDisabled: false },
)
const emit = defineEmits<{
'update:q': [value: string]
'update:filter': [value: MediaFilter]
'update:sort': [value: MediaSort]
'update:view': [value: MediaView]
purge: []
}>()
const searchText = ref(props.controls.q)
@@ -191,7 +197,9 @@ const sortItems = computed(() =>
</template>
</UInput>
<div class="order-3 flex gap-2 md:order-none md:mb-1.5">
<div
class="order-3 flex flex-wrap gap-x-2 gap-y-3 md:order-none md:mb-1.5 md:flex-nowrap md:gap-2"
>
<UDropdownMenu :items="sortItems" :content="{ align: 'start' }">
<UButton
color="neutral"
@@ -205,6 +213,20 @@ const sortItems = computed(() =>
</UButton>
</UDropdownMenu>
<UButton
v-if="showPurge"
data-testid="purge-duplicates"
color="neutral"
variant="outline"
icon="i-lucide-merge"
:disabled="purgeDisabled"
class="order-last h-11 w-full justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:order-none md:h-8 md:w-auto"
:ui="{ leadingIcon: 'size-4' }"
@click="emit('purge')"
>
{{ t('Purge duplicates') }}
</UButton>
<div role="group" :aria-label="t('View')" class="flex shrink-0 border border-accented">
<UButton
v-for="option in VIEW_OPTIONS"
+9 -4
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { translate } from '@/i18n'
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
const DEFAULT_MESSAGE =
"You're offline. You can keep writing, but saving and publishing will wait until you're back online."
const ROOMY_BLEED_ROOT = 'w-auto -mx-4 -mt-5 mb-5 lg:-mx-8 lg:-mt-8 lg:mb-8'
const BLEED_UI = {
root: 'w-auto -mx-4 -mt-4 mb-4 lg:-mx-6 lg:-mt-6 lg:mb-6',
container: 'h-auto min-h-10 max-w-none justify-start gap-2.5 px-4 py-2.5 sm:px-4 lg:px-4',
@@ -14,15 +16,18 @@ const BLEED_UI = {
title: 'whitespace-normal font-normal text-wrap',
}
withDefaults(
const props = withDefaults(
defineProps<{
message?: string
icon?: string
bleed?: boolean
roomy?: boolean
}>(),
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false },
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false, roomy: false },
)
const bleedUi = computed(() => (props.roomy ? { ...BLEED_UI, root: ROOMY_BLEED_ROOT } : BLEED_UI))
const isOffline = ref(!navigator.onLine)
function updateOnlineStatus() {
@@ -46,6 +51,6 @@ onUnmounted(() => {
color="neutral"
:icon="icon"
:title="translate(message)"
:ui="bleed ? BLEED_UI : undefined"
:ui="bleed ? bleedUi : undefined"
/>
</template>
@@ -0,0 +1,375 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import MediaThumb from '@/components/MediaThumb.vue'
import { ApiError } from '@/composables/useApi'
import { useMediaPurge, type PurgePreview, type PurgeResult } from '@/composables/useMediaPurge'
import {
PURGE_BUSY_COPY,
PURGE_STALE_COPY,
REVIEW_DUPLICATES_PATH,
isForbidden,
isHandledGlobally,
keptLine,
mergedCopies,
moreGroupsLine,
pluralGroups,
previewSummary,
thumbnailSource,
} from '@/lib/mediaPurge'
import { useSessionStore } from '@/stores/session'
type Status = 'loading' | 'ready' | 'purging' | 'nothing' | 'error' | 'partial' | 'conflict'
type Notice = 'failed' | 'partial' | 'stale' | 'busy'
const SKELETON_DELAY_MS = 150
const SKELETON_ROWS = 3
const HTTP_CONFLICT = 409
const FAILED_COPY = "Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
const DESCRIPTION =
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
const NOTHING_COPY = 'No duplicate is used in an article, so there is nothing to merge.'
const PARTIAL_COPY =
"Some groups couldn't be merged. {0} of {1} groups were purged. Try again to finish."
const BUTTON_BASE = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
const MODAL_CONTENT =
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-md sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0'
const emit = defineEmits<{
close: []
purged: [result: PurgeResult]
partial: [result: PurgeResult]
review: []
}>()
const { getPreview, purge } = useMediaPurge()
const session = useSessionStore()
const open = ref(true)
const status = ref<Status>('loading')
const notice = ref<Notice | null>(null)
const preview = ref<PurgePreview | null>(null)
const partialCounts = ref({ purged: 0, total: 0 })
const refreshing = ref(false)
const showSkeleton = ref(false)
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
const purging = computed(() => status.value === 'purging')
const busy = computed(() => purging.value || refreshing.value)
const nothing = computed(() => status.value === 'nothing')
const partial = computed(() => status.value === 'partial')
const waitingForPreview = computed(() => status.value === 'loading')
const canConfirm = computed(() => preview.value !== null && !busy.value)
const confirmLabel = computed(() => {
if (purging.value) {
return t('Purging…')
}
return preview.value
? t('Purge {0}', [pluralGroups(preview.value.groups)])
: t('Purge duplicates')
})
const noticeText = computed(() => {
if (notice.value === 'partial') {
return t(PARTIAL_COPY, [partialCounts.value.purged, partialCounts.value.total])
}
return {
failed: t(FAILED_COPY),
stale: t(PURGE_STALE_COPY),
busy: t(PURGE_BUSY_COPY),
none: '',
}[notice.value ?? 'none']
})
const noticeIsError = computed(() => notice.value === 'failed')
const ui = computed(() => ({
content: MODAL_CONTENT,
header: nothing.value
? 'min-h-0 justify-center px-6 pt-7 pb-0 text-center sm:px-6'
: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
title: 'text-lg/6 font-semibold',
description: nothing.value
? 'mx-auto mt-3 max-w-85 text-sm text-muted text-pretty'
: 'mt-1 text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}))
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
function giveUpAfterAuthFailure(error: unknown) {
if (isForbidden(error)) {
session.refresh()
close()
}
}
function applyPreview(next: PurgePreview) {
preview.value = next
if (next.groups === 0) {
status.value = 'nothing'
notice.value = null
}
}
async function loadPreview() {
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const first = await getPreview()
applyPreview(first)
if (first.groups > 0) {
status.value = 'ready'
}
} catch (error) {
if (isHandledGlobally(error)) {
giveUpAfterAuthFailure(error)
return
}
status.value = 'error'
notice.value = 'failed'
} finally {
clearSkeletonTimer()
showSkeleton.value = false
}
}
async function refreshPreview() {
refreshing.value = true
try {
applyPreview(await getPreview())
} catch (error) {
if (isHandledGlobally(error)) {
giveUpAfterAuthFailure(error)
}
} finally {
refreshing.value = false
}
}
function close() {
open.value = false
emit('close')
}
async function settle(result: PurgeResult) {
if (result.failed_groups === 0) {
emit('purged', result)
close()
return
}
partialCounts.value = { purged: result.groups_purged, total: result.groups_total }
notice.value = 'partial'
status.value = 'partial'
emit('partial', result)
await refreshPreview()
}
async function fail(error: unknown) {
if (isHandledGlobally(error)) {
giveUpAfterAuthFailure(error)
return
}
if (error instanceof ApiError && error.status === HTTP_CONFLICT) {
notice.value = error.body === PURGE_BUSY_COPY ? 'busy' : 'stale'
status.value = 'conflict'
await refreshPreview()
return
}
notice.value = 'failed'
status.value = 'error'
}
async function confirm() {
if (!preview.value || busy.value) {
return
}
const { fingerprint } = preview.value
status.value = 'purging'
try {
await settle(await purge(fingerprint))
} catch (error) {
await fail(error)
}
}
function onOpenChange(value: boolean) {
if (!value && busy.value) {
return
}
if (!value) {
close()
}
}
function onCancel() {
if (!busy.value) {
close()
}
}
function onReview(event: MouseEvent) {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return
}
event.preventDefault()
emit('review')
}
onMounted(async () => {
loadPreview()
await nextTick()
cancelButton.value?.$el?.focus()
})
onBeforeUnmount(clearSkeletonTimer)
</script>
<template>
<UModal
:open="open"
:title="nothing ? t('Nothing to purge') : t('Purge duplicates?')"
:description="nothing ? t(NOTHING_COPY) : t(DESCRIPTION)"
:dismissible="!busy"
:close="false"
:content="{ role: 'alertdialog', 'aria-busy': busy ? 'true' : undefined }"
:ui="ui"
@update:open="onOpenChange"
>
<template v-if="nothing" #title>
<span class="flex flex-col items-center gap-3">
<span
class="flex size-11 items-center justify-center bg-(--rc-info-soft) text-info"
>
<UIcon name="i-lucide-copy" class="size-5" />
</span>
{{ t('Nothing to purge') }}
</span>
</template>
<template v-if="!nothing" #body>
<div class="flex flex-col gap-4">
<UAlert
v-if="notice && noticeText"
:color="noticeIsError ? 'error' : 'warning'"
variant="soft"
:icon="noticeIsError ? 'i-lucide-circle-x' : 'i-lucide-triangle-alert'"
:description="noticeText"
:close="false"
role="alert"
/>
<template v-if="waitingForPreview && showSkeleton">
<USkeleton
data-testid="purge-skeleton"
class="h-4 w-4/5 rounded-none bg-accented"
/>
<div class="border border-default">
<div
v-for="row in SKELETON_ROWS"
:key="row"
class="flex items-center gap-3 border-muted px-3 py-2 not-first:border-t"
>
<USkeleton class="h-7.5 w-10 rounded-none bg-accented" />
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<USkeleton class="h-3.25 w-9/20 rounded-none bg-accented" />
<USkeleton class="h-2.75 w-3/5 rounded-none bg-accented" />
</div>
</div>
</div>
</template>
<template v-if="preview">
<span data-testid="purge-summary" class="font-medium text-highlighted">{{
previewSummary(preview)
}}</span>
<ul data-testid="purge-groups" class="border border-default">
<li
v-for="group in preview.list"
:key="group.keep_id"
data-testid="purge-group"
class="flex items-center gap-3 border-muted px-3 py-2 not-first:border-t"
>
<MediaThumb
:item="thumbnailSource(group.url, group.filename)"
size="sm"
class="h-7.5 w-10 border border-default"
/>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate font-medium text-highlighted">{{
group.filename
}}</span>
<span class="text-xs/5 text-muted">{{
keptLine(group.used_in)
}}</span>
</div>
<span class="text-xs whitespace-nowrap text-toned">{{
mergedCopies(group.merged)
}}</span>
</li>
<li
v-if="preview.more > 0"
data-testid="purge-more"
class="border-t border-muted px-3 py-2 text-muted"
>
{{ moreGroupsLine(preview.more) }}
</li>
</ul>
<a
:href="REVIEW_DUPLICATES_PATH"
class="inline-flex min-h-11 items-center gap-1 self-start font-medium text-highlighted underline underline-offset-3 md:min-h-5"
@click="onReview"
>
{{ t('Review in Duplicates') }}
<UIcon name="i-lucide-arrow-right" class="size-3.5" />
</a>
</template>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
ref="cancelButton"
color="neutral"
variant="outline"
block
:class="[
BUTTON_BASE,
'text-highlighted disabled:text-dimmed disabled:opacity-100',
]"
:disabled="busy"
@click="onCancel"
>
{{ nothing || partial ? t('Close') : t('Cancel') }}
</UButton>
<UButton
v-if="partial"
color="error"
icon="i-lucide-rotate-cw"
block
:class="BUTTON_BASE"
:disabled="!canConfirm"
@click="confirm"
>
{{ t('Retry') }}
</UButton>
<UButton
v-else-if="!nothing"
color="error"
:loading="purging"
block
:class="BUTTON_BASE"
:disabled="!canConfirm"
@click="confirm"
>
{{ confirmLabel }}
</UButton>
</div>
</template>
</UModal>
</template>
@@ -0,0 +1,129 @@
import { onBeforeUnmount, ref } from 'vue'
import {
useMediaPurge,
type PurgeSettings,
type PurgeSettingsPatch,
} from '@/composables/useMediaPurge'
import { isForbidden, isHandledGlobally } from '@/lib/mediaPurge'
export type CleanupLoadState = 'loading' | 'ready' | 'error'
export type SaveStatus = 'idle' | 'saving' | 'saved'
export type SaveFailure = 'enabled' | 'days'
const SKELETON_DELAY_MS = 150
const SAVED_VISIBLE_MS = 2000
export function useMediaCleanupSettings(onForbidden: () => void) {
const { getSettings, updateSettings } = useMediaPurge()
const settings = ref<PurgeSettings | null>(null)
const loadState = ref<CleanupLoadState>('loading')
const showSkeleton = ref(false)
const saveStatus = ref<SaveStatus>('idle')
const saveFailure = ref<SaveFailure | null>(null)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let savedTimer: ReturnType<typeof setTimeout> | null = null
let latestLoad = 0
let pendingSaves = 0
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
function clearSavedTimer() {
if (savedTimer !== null) {
clearTimeout(savedTimer)
savedTimer = null
}
}
async function load() {
latestLoad += 1
const ticket = latestLoad
loadState.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const loaded = await getSettings()
if (ticket === latestLoad) {
settings.value = loaded
loadState.value = 'ready'
}
} catch (error) {
if (ticket !== latestLoad) {
return
}
if (isHandledGlobally(error)) {
if (isForbidden(error)) {
onForbidden()
}
return
}
loadState.value = 'error'
} finally {
if (ticket === latestLoad) {
clearSkeletonTimer()
showSkeleton.value = false
}
}
}
async function refresh() {
try {
settings.value = await getSettings()
} catch (error) {
if (isForbidden(error)) {
onForbidden()
}
}
}
function finishSaving(succeeded: boolean) {
if (pendingSaves > 0) {
return
}
if (!succeeded) {
saveStatus.value = 'idle'
return
}
saveStatus.value = 'saved'
savedTimer = setTimeout(() => {
saveStatus.value = 'idle'
}, SAVED_VISIBLE_MS)
}
async function save(patch: PurgeSettingsPatch, field: SaveFailure) {
pendingSaves += 1
clearSavedTimer()
saveStatus.value = 'saving'
let succeeded = false
try {
settings.value = await updateSettings(patch)
saveFailure.value = null
succeeded = true
} catch (error) {
if (isForbidden(error)) {
onForbidden()
} else if (!isHandledGlobally(error)) {
saveFailure.value = field
}
} finally {
pendingSaves -= 1
finishSaving(succeeded)
}
return succeeded
}
onBeforeUnmount(() => {
clearSkeletonTimer()
clearSavedTimer()
})
return { settings, loadState, showSkeleton, saveStatus, saveFailure, load, refresh, save }
}
+78
View File
@@ -0,0 +1,78 @@
import { useApi } from '@/composables/useApi'
import { bumpMediaRevision } from '@/lib/mediaRevision'
export interface PurgeSettings {
auto_purge_enabled: boolean
auto_purge_interval_days: number
last_purge_at: string | null
last_purge_merged: number
last_purge_articles_updated: number
enabled_at: string | null
next_purge_at: string | null
}
export type PurgeSettingsPatch =
| { auto_purge_enabled: boolean }
| { auto_purge_interval_days: number }
export interface PurgePreviewGroup {
keep_id: number
filename: string
url: string
used_in: number
merged: number
}
export interface PurgePreview {
groups: number
copies: number
articles: number
list: PurgePreviewGroup[]
more: number
fingerprint: string
}
export interface PurgeResult {
groups_total: number
groups_purged: number
merged: number
articles_updated: number
failed_groups: number
}
const SETTINGS_PATH = '/v1/media/purge-settings'
const PREVIEW_PATH = '/v1/media/purge-preview'
const PURGE_PATH = '/v1/media/purge'
const JSON_HEADERS = { 'Content-Type': 'application/json' }
export function useMediaPurge() {
const { request } = useApi()
function getSettings() {
return request<PurgeSettings>(SETTINGS_PATH)
}
function updateSettings(patch: PurgeSettingsPatch) {
return request<PurgeSettings>(SETTINGS_PATH, {
method: 'PATCH',
headers: JSON_HEADERS,
body: JSON.stringify(patch),
})
}
function getPreview() {
return request<PurgePreview>(PREVIEW_PATH)
}
async function purge(fingerprint: string) {
const result = await request<PurgeResult>(PURGE_PATH, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ fingerprint }),
})
bumpMediaRevision()
return result
}
return { getSettings, updateSettings, getPreview, purge }
}
+49
View File
@@ -955,6 +955,55 @@ export const messages = {
'Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default.':
'Les routes d’articles acceptent `?format=markdown`, `?format=html` ou `?format=json` pour choisir le format de `body`. Avec `json`, `body` est la structure JSON stockée. Les articles ont `body_format: markdown` par défaut.',
Meaning: 'Signification',
'Media cleanup': 'Nettoyage des médias',
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.':
'Fusionnez les images identiques pour garder votre médiathèque ordonnée. Les articles qui utilisent une copie passent à la plus utilisée.',
'Automatic purge': 'Purge automatique',
'Runs in the background. Off by default.': 'S’exécute en arrière-plan. Désactivée par défaut.',
Every: 'Tous les',
'Between 1 and 365 days.': 'Entre 1 et 365 jours.',
days: 'jours',
'Never run': 'Jamais exécutée',
'Last purge: {0} · {1} merged · {2} updated':
'Dernière purge : {0} · {1} fusionnées · {2} mis à jour',
'Next purge: {0}': 'Prochaine purge : {0}',
"Couldn't save. Try again in a moment.": 'Impossible d’enregistrer. Réessayez dans un instant.',
'Enter a whole number of days between 1 and 365.':
'Saisissez un nombre entier de jours entre 1 et 365.',
'Only owners and admins can change this.':
'Seuls les propriétaires et les administrateurs peuvent modifier ceci.',
'Purge now': 'Purger maintenant',
'Merge every group of duplicates that has an image in use, right away.':
'Fusionnez tout de suite chaque groupe de doublons dont une image est utilisée.',
'Purge duplicates': 'Purger les doublons',
"You're offline. Changing automatic purge and purging duplicates need a connection.":
'Vous êtes hors ligne. Modifier la purge automatique et purger les doublons nécessitent une connexion.',
'Purge duplicates?': 'Purger les doublons ?',
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone.":
'Les articles qui utilisent un doublon passeront à la copie la plus utilisée de cette image, et les autres copies seront supprimées. Cette action est irréversible.',
'1 group': '1 groupe',
'{0} groups': '{0} groupes',
'{0} · {1} will be deleted · {2} will be updated':
'{0} · {1} seront supprimées · {2} seront mis à jour',
'Kept · used in {0}': 'Conservée · utilisée dans {0}',
'{0} merged': '{0} fusionnées',
'and 1 more group': 'et 1 autre groupe',
'and {0} more groups': 'et {0} autres groupes',
'Review in Duplicates': 'Voir dans Doublons',
'Purge {0}': 'Purger {0}',
'Purging…': 'Purge…',
Close: 'Fermer',
'Nothing to purge': 'Rien à purger',
'No duplicate is used in an article, so there is nothing to merge.':
'Aucun doublon n’est utilisé dans un article, il n’y a donc rien à fusionner.',
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment.":
'Impossible de purger les doublons. Rien n’a été modifié. Réessayez dans un instant.',
"Some groups couldn't be merged. {0} of {1} groups were purged. Try again to finish.":
'Certains groupes n’ont pas pu être fusionnés. {0} groupes sur {1} ont été purgés. Réessayez pour terminer.',
'The library changed, so nothing was purged. Check the new summary and try again.':
'La médiathèque a changé, rien n’a donc été purgé. Vérifiez le nouveau récapitulatif et réessayez.',
'A purge is already running.': 'Une purge est déjà en cours.',
'Duplicates purged': 'Doublons purgés',
} as const
export type MessageKey = keyof typeof messages
+107
View File
@@ -0,0 +1,107 @@
import { t } from '@/i18n'
import { ApiError } from '@/composables/useApi'
import type { PurgeSettings } from '@/composables/useMediaPurge'
import { formatMediaDate, pluralArticles } from '@/lib/mediaFormat'
export const MIN_INTERVAL_DAYS = 1
export const MAX_INTERVAL_DAYS = 365
export const DEFAULT_INTERVAL_DAYS = 30
export const PURGE_TOAST_DURATION_MS = 5000
export const REVIEW_DUPLICATES_PATH = '/media?filter=duplicates'
export const PURGE_STALE_COPY =
'The library changed, so nothing was purged. Check the new summary and try again.'
export const PURGE_BUSY_COPY = 'A purge is already running.'
const HTTP_UNAUTHORIZED = 401
const HTTP_FORBIDDEN = 403
const WHOLE_NUMBER = /^\d+$/
const SVG_EXTENSION = /\.svg$/i
const PNG_EXTENSION = /\.png$/i
export function isForbidden(error: unknown) {
return error instanceof ApiError && error.status === HTTP_FORBIDDEN
}
export function isHandledGlobally(error: unknown) {
return error instanceof ApiError && (error.status === HTTP_UNAUTHORIZED || isForbidden(error))
}
export function parseIntervalDays(text: string) {
const trimmed = text.trim()
if (!WHOLE_NUMBER.test(trimmed)) {
return null
}
const days = Number.parseInt(trimmed, 10)
return days >= MIN_INTERVAL_DAYS && days <= MAX_INTERVAL_DAYS ? days : null
}
export function pluralCopies(count: number) {
return t(count === 1 ? '1 copy' : '{0} copies', [count])
}
export function pluralGroups(count: number) {
return t(count === 1 ? '1 group' : '{0} groups', [count])
}
export function mergedCopies(count: number) {
return t('{0} merged', [pluralCopies(count)])
}
export function lastPurgeLine(settings: PurgeSettings | null) {
if (!settings?.last_purge_at) {
return t('Never run')
}
return t('Last purge: {0} · {1} merged · {2} updated', [
formatMediaDate(settings.last_purge_at),
pluralCopies(settings.last_purge_merged ?? 0),
pluralArticles(settings.last_purge_articles_updated ?? 0),
])
}
export function nextPurgeLine(iso: string) {
return t('Next purge: {0}', [formatMediaDate(iso)])
}
export function previewSummary(counts: { groups: number; copies: number; articles: number }) {
return t('{0} · {1} will be deleted · {2} will be updated', [
pluralGroups(counts.groups),
pluralCopies(counts.copies),
pluralArticles(counts.articles),
])
}
export function keptLine(usedIn: number) {
return t('Kept · used in {0}', [pluralArticles(usedIn)])
}
export function moreGroupsLine(more: number) {
return t(more === 1 ? 'and 1 more group' : 'and {0} more groups', [more])
}
export function thumbnailSource(url: string, filename: string) {
let contentType = ''
if (SVG_EXTENSION.test(filename)) {
contentType = 'image/svg+xml'
} else if (PNG_EXTENSION.test(filename)) {
contentType = 'image/png'
}
return { url, content_type: contentType, sha256: '' }
}
export function purgedToast(result: { merged: number; articles_updated: number }) {
return {
id: 'media-purged',
color: 'success' as const,
icon: 'i-lucide-circle-check',
get title() {
return t('Duplicates purged')
},
get description() {
return t('{0} merged. {1} updated.', [
pluralCopies(result.merged),
pluralArticles(result.articles_updated),
])
},
duration: PURGE_TOAST_DURATION_MS,
}
}
+52
View File
@@ -21,6 +21,7 @@ import MediaTable from '@/components/MediaTable.vue'
import MediaToolbar from '@/components/MediaToolbar.vue'
import MediaUploadPanel from '@/components/MediaUploadPanel.vue'
import MergeMediaDialog from '@/components/MergeMediaDialog.vue'
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
import RenameMediaDialog from '@/components/RenameMediaDialog.vue'
import { ApiError } from '@/composables/useApi'
import {
@@ -32,6 +33,7 @@ import {
} from '@/composables/useMedia'
import { useMediaSelection } from '@/composables/useMediaSelection'
import { useMediaUpload } from '@/composables/useMediaUpload'
import type { PurgeResult } from '@/composables/useMediaPurge'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
import { joinNames } from '@/lib/mediaFormat'
@@ -41,7 +43,9 @@ import {
toRouteQuery,
type MediaControls,
} from '@/lib/mediaQuery'
import { purgedToast } from '@/lib/mediaPurge'
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 48rem)'
const TOAST_DURATION_MS = 5000
@@ -71,6 +75,7 @@ const OFFLINE_COPY =
const route = useRoute()
const router = useRouter()
const toast = useLocalizedToast()
const session = useSessionStore()
const media = useMedia()
const selection = useMediaSelection(media.items)
const upload = useMediaUpload({ onAdded: refreshInBackground })
@@ -91,12 +96,14 @@ const deleteOpen = ref(false)
const bulkDeleteOpen = ref(false)
const bulkTargets = ref<MediaItem[]>([])
const mergeTarget = ref<{ group: DuplicateGroup; keep: MediaItem } | null>(null)
const purgeOpen = ref(false)
const dragDepth = ref(0)
const announcementSource = ref<() => string>(() => '')
const announcement = computed(() => announcementSource.value())
const usage = ref<MediaUsageItem[]>([])
const copies = ref<MediaItem[]>([])
let opener: HTMLElement | null = null
let purgeChangedList = false
let skipFocusRestore = false
let pendingSelectId: number | null = null
let bulkDeletedTotal = 0
@@ -138,6 +145,12 @@ const filterLabel = computed(
],
)
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
const canPurge = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const showPurge = computed(
() => canPurge.value && isDuplicates.value && showList.value && groups.value.length > 0,
)
const dragging = computed(() => dragDepth.value > 0)
const uploadBlocked = computed(() => offline.value || upload.limited.value)
const uploadLabel = computed(() =>
@@ -150,6 +163,7 @@ const dialogOpen = computed(
renameOpen.value ||
deleteOpen.value ||
bulkDeleteOpen.value ||
purgeOpen.value ||
mergeTarget.value !== null,
)
const hasSelection = computed(() => selection.count.value > 0)
@@ -724,6 +738,33 @@ function openMerge(group: DuplicateGroup, keep: MediaItem) {
mergeTarget.value = { group, keep }
}
function openPurge(event?: Event) {
if (offline.value) {
return
}
opener = (event?.currentTarget as HTMLElement | null) ?? null
purgeChangedList = false
purgeOpen.value = true
}
function onPurged(result: PurgeResult) {
toast.add(purgedToast(result))
purgeChangedList = true
}
function onPurgePartial() {
purgeChangedList = true
}
function closePurge() {
purgeOpen.value = false
if (purgeChangedList) {
purgeChangedList = false
refreshInBackground()
}
restoreFocusToOpener(null)
}
function mergeText(merged: number, articlesUpdated: number) {
const copiesText = merged === 1 ? t('1 copy') : t('{0} copies', [merged])
const articlesText =
@@ -873,10 +914,13 @@ async function mergeAction() {
<MediaToolbar
:controls="controls"
:counts="counts"
:show-purge="showPurge"
:purge-disabled="offline"
@update:q="updateControls({ q: $event })"
@update:filter="updateControls({ filter: $event })"
@update:sort="updateControls({ sort: $event })"
@update:view="updateControls({ view: $event })"
@purge="openPurge"
/>
<MediaUploadPanel
@@ -1022,6 +1066,14 @@ async function mergeAction() {
@close="bulkDeleteOpen = false"
/>
<PurgeDuplicatesDialog
v-if="purgeOpen"
@close="closePurge"
@purged="onPurged"
@partial="onPurgePartial"
@review="purgeOpen = false"
/>
<MergeMediaDialog
v-if="mergeTarget"
:keep="mergeTarget.keep"
+10 -1
View File
@@ -5,6 +5,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
import RestartOverlay from '@/components/RestartOverlay.vue'
import UpdatesCard from '@/components/UpdatesCard.vue'
import { ApiError } from '@/composables/useApi'
@@ -19,6 +20,8 @@ import { useUpdateStore, type RestartOutcome, type RestartRequestResult } from '
const SKELETON_DELAY_MS = 150
const POLL_INTERVAL_MS = 1000
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
const OFFLINE_COPY =
"You're offline. Changing automatic purge and purging duplicates need a connection."
const RESTART_FAILED_COPY =
"Couldn't start the restart. Nothing was changed and your site is still running. Try again in a moment."
const RESTART_WARNING =
@@ -372,7 +375,11 @@ onBeforeUnmount(() => {
</script>
<template>
<DashboardShell>
<DashboardShell
bleed-offline-banner
:offline-message="t(OFFLINE_COPY)"
offline-icon="i-lucide-wifi-off"
>
<RestartOverlay
v-if="update.restartRun"
:kind="update.restartRun.kind"
@@ -449,6 +456,8 @@ onBeforeUnmount(() => {
@update="onUpdate"
@restart="onRestart"
/>
<MediaCleanupCard />
</div>
</DashboardShell>
</template>
@@ -0,0 +1,217 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError } from '@/composables/useApi'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
callsTo,
deferred,
dialog,
findButton,
fixture,
listBody,
listCalls,
mountPage,
settle,
textOf,
unmountPage,
type Handlers,
} from './mediaTestKit'
import { FAILED_COPY, previewOf, purgeResult } from './purgeTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const DUPLICATES = '/media?filter=duplicates'
const DUPLICATE_IDS = [5, 6, 7, 9, 12]
const COUNTS = { all: 12, unused: 2, duplicates: 2 }
function duplicatesList(ids = DUPLICATE_IDS): Handlers['list'] {
return (path) =>
path.includes('filter=duplicates')
? listBody(fixture(ids), { ...COUNTS, duplicates: ids.length ? 2 : 0 })
: listBody(fixture())
}
function purgeButton() {
return document.body.querySelector<HTMLButtonElement>('[data-testid="purge-duplicates"]')
}
async function openFromMedia(handlers: Handlers = {}) {
await mountPage({ list: duplicatesList(), preview: () => previewOf(), ...handlers }, DUPLICATES)
purgeButton()!.focus()
purgeButton()!.click()
await flushPromises()
}
beforeEach(() => {
toasts.add.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountPage)
describe('The Duplicates toolbar button', () => {
it.each(['owner', 'admin'])('shows for %s on the Duplicates tab with groups', async (role) => {
await mountPage({ list: duplicatesList() }, DUPLICATES, undefined, { role })
expect(purgeButton()).not.toBeNull()
expect(textOf(purgeButton())).toBe('Purge duplicates')
expect(purgeButton()!.querySelector('svg')).not.toBeNull()
})
it.each([
['the filter is All', '/media', 'owner', duplicatesList()],
['the filter is Unused', '/media?filter=unused', 'owner', duplicatesList()],
['there is no group', DUPLICATES, 'owner', duplicatesList([])],
['the role is not owner or admin', DUPLICATES, 'editor', duplicatesList()],
])('is hidden when %s', async (_name, url, role, list) => {
await mountPage({ list }, url, undefined, { role })
expect(purgeButton()).toBeNull()
})
it('is disabled offline, opens nothing and keeps the existing banner', async () => {
await mountPage({ list: duplicatesList(), preview: () => previewOf() }, DUPLICATES)
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
purgeButton()!.click()
await flushPromises()
expect(purgeButton()!.disabled).toBe(true)
expect(document.body.textContent).toContain("You're offline.")
expect(dialog()).toBeNull()
expect(callsTo('GET', /purge-preview/)).toHaveLength(0)
})
it('sits between the sort menu and the view toggle, full width and 44 px high when stacked', async () => {
await mountPage({ list: duplicatesList() }, DUPLICATES, MOBILE_WIDTH)
const sort = document.body.querySelector('[aria-label="Sort"]')!
const toggle = document.body.querySelector('[role="group"][aria-label="View"]')!
const button = purgeButton()!
expect(sort.compareDocumentPosition(button)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(button.compareDocumentPosition(toggle)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(button.className).toContain('h-11')
expect(button.className).toContain('w-full')
})
})
describe('The purge dialog from Media', () => {
it('opens in the loading state and sends the preview request', async () => {
const pending = deferred<unknown>()
await openFromMedia({ preview: () => pending.promise })
expect(callsTo('GET', /purge-preview/)).toHaveLength(1)
expect(textOf(dialog())).toContain('Purge duplicates?')
expect(findButton('Purge duplicates', dialog()!)!.disabled).toBe(true)
})
it('confirms with the preview fingerprint and shows the same summary', async () => {
await openFromMedia()
expect(textOf(dialog()!.querySelector('[data-testid="purge-summary"]'))).toBe(
'4 groups · 9 copies will be deleted · 12 articles will be updated',
)
findButton('Purge 4 groups', dialog()!)!.click()
await flushPromises()
const [call] = callsTo('POST', /\/v1\/media\/purge$/)
expect(JSON.parse(call![1].body)).toEqual({ fingerprint: 'ab12' })
})
it('shows the error notice and keeps the dialog open on a failure', async () => {
await openFromMedia({ purge: () => Promise.reject(new ApiError(500)) })
findButton('Purge 4 groups', dialog()!)!.click()
await settle()
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toBe(FAILED_COPY)
})
it('toasts, reloads the list and counts, and drops the button with the last group', async () => {
let purged = false
await openFromMedia({
list: (path) =>
path.includes('filter=duplicates')
? listBody(fixture(purged ? [] : DUPLICATE_IDS), {
all: 12,
unused: 2,
duplicates: purged ? 0 : 2,
})
: listBody(fixture()),
purge: () => {
purged = true
return purgeResult()
},
})
const reloads = listCalls().length
findButton('Purge 4 groups', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Duplicates purged',
description: '9 copies merged. 12 articles updated.',
duration: 5000,
}),
)
expect(listCalls().length).toBe(reloads + 1)
expect(document.body.textContent).toContain('No duplicates')
expect(purgeButton()).toBeNull()
})
it('reloads the list when Close follows a partial result', async () => {
await openFromMedia({
purge: () => purgeResult({ groups_purged: 3, failed_groups: 1 }),
})
findButton('Purge 4 groups', dialog()!)!.click()
await settle()
expect(toasts.add).not.toHaveBeenCalled()
const reloads = listCalls().length
findButton('Close', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(listCalls().length).toBe(reloads + 1)
})
it('only closes the dialog when Review in Duplicates is followed', async () => {
await openFromMedia()
const before = router.currentRoute.value.fullPath
dialog()!.querySelector('a')!.click()
await settle()
expect(dialog()).toBeNull()
expect(router.currentRoute.value.fullPath).toBe(before)
})
})
describe('Merge into this one stays', () => {
it('still sends the merge request with the ids', async () => {
const merge = vi.fn(() => ({ articles_updated: 5, merged: 2 }))
await mountPage({ list: duplicatesList(), merge }, DUPLICATES)
const first = findButton('Merge into this one')!
first.click()
await flushPromises()
findButton('Merge 2 copies', dialog()!)!.click()
await settle()
expect(merge).toHaveBeenCalledWith({ keep_id: 12, merge_ids: [5, 6] })
})
})
@@ -0,0 +1,529 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
import {
BUSY_COPY,
FAILED_COPY,
PREVIEW_PATH,
PURGE_PATH,
SETTINGS_PATH,
STALE_COPY,
bodyOf,
callsTo,
deferredValue,
dialogEl,
emptyPreview,
enabledSettings,
findButton,
goOffline,
groupRows,
isBefore,
lastLine,
manyPreview,
mountSettings,
nextLine,
openDialog,
previewOf,
pressEscape,
purgeButton,
purgeResult,
singlePreview,
textOf,
unmountSettings,
type PurgeMocks,
} from './purgeTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const DESCRIPTION =
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
const SUMMARY = '4 groups · 9 copies will be deleted · 12 articles will be updated'
const LAST_RUN_LINE = 'Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated'
const AFTER_PURGE_LINE = 'Last purge: 5 Oct 2026 · 9 copies merged · 12 articles updated'
async function opened(mocks: PurgeMocks = {}) {
await mountSettings({ settings: () => enabledSettings(), ...mocks })
return openDialog()
}
function buttonLabels() {
return Array.from(dialogEl()!.querySelectorAll('button')).map(textOf)
}
function confirmButton(label: string) {
return findButton(label, dialogEl()!)!
}
function noticeText() {
return textOf(dialogEl()!.querySelector('[role="alert"]'))
}
function afterRefresh(settings = enabledSettings()) {
let reads = 0
return () => (reads++ === 0 ? enabledSettings() : settings)
}
function purgedSettings(overrides = {}) {
return enabledSettings({
last_purge_at: '2026-10-05T09:00:00Z',
last_purge_merged: 9,
last_purge_articles_updated: 12,
next_purge_at: '2026-11-04T09:00:00Z',
...overrides,
})
}
beforeEach(() => {
toasts.add.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountSettings)
describe('Purge dialog: opening', () => {
it('sends the preview request, shows the title, description, focuses Cancel and delays the skeleton', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const pending = deferredValue<unknown>()
await opened({ preview: () => pending.promise })
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(1)
const box = dialogEl()!
expect(textOf(box)).toContain('Purge duplicates?')
expect(textOf(box)).toContain(DESCRIPTION)
expect(document.activeElement).toBe(findButton('Cancel', box))
vi.advanceTimersByTime(149)
await flushPromises()
expect(box.querySelector('[data-testid="purge-skeleton"]')).toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
expect(box.querySelector('[data-testid="purge-skeleton"]')).not.toBeNull()
expect(box.querySelectorAll('.animate-pulse').length).toBeGreaterThan(3)
expect(confirmButton('Purge duplicates').disabled).toBe(true)
expect(buttonLabels()).toEqual(['Cancel', 'Purge duplicates'])
})
it('is not openable while offline', async () => {
await mountSettings({ settings: () => enabledSettings() })
await goOffline()
purgeButton().click()
await flushPromises()
expect(purgeButton().disabled).toBe(true)
expect(dialogEl()).toBeNull()
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(0)
})
})
describe('Purge dialog: the preview', () => {
it('shows the summary, the rows, the link and both buttons', async () => {
await opened()
const box = dialogEl()!
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(SUMMARY)
const rows = groupRows()
expect(rows).toHaveLength(4)
expect(
rows.map((row) =>
Array.from(row.querySelectorAll('span')).map(textOf).filter(Boolean).join(' '),
),
).toEqual([
'floorboards.jpg Kept · used in 4 articles 2 copies merged',
'IMG_4471.jpg Kept · used in 2 articles 1 copy merged',
'kitchen-after.webp Kept · used in 3 articles 3 copies merged',
'logo-renew.svg Kept · used in 5 articles 3 copies merged',
])
expect(rows[0]!.querySelector('img')!.getAttribute('src')).toBe('/media/floorboards.jpg')
expect(box.querySelector('[data-testid="purge-more"]')).toBeNull()
const link = box.querySelector('a')!
expect(link.getAttribute('href')).toBe('/media?filter=duplicates')
expect(textOf(link)).toBe('Review in Duplicates')
expect(link.querySelector('svg')).not.toBeNull()
expect(buttonLabels()).toEqual(['Cancel', 'Purge 4 groups'])
expect(confirmButton('Purge 4 groups').disabled).toBe(false)
})
it.each([
[previewOf(), SUMMARY, 'Purge 4 groups', 'Kept · used in 4 articles', '2 copies merged'],
[
singlePreview(2, 1, 2),
'1 group · 1 copy will be deleted · 2 articles will be updated',
'Purge 1 group',
'Kept · used in 2 articles',
'1 copy merged',
],
[
singlePreview(1, 1, 1),
'1 group · 1 copy will be deleted · 1 article will be updated',
'Purge 1 group',
'Kept · used in 1 article',
'1 copy merged',
],
])(
'pluralises the summary, the confirm and the rows',
async (preview, summary, confirm, kept, merged) => {
await opened({ preview: () => preview })
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-summary"]'))).toBe(summary)
expect(buttonLabels()).toEqual(['Cancel', confirm])
const row = textOf(groupRows()[0])
expect(row).toContain(kept)
expect(row).toContain(merged)
},
)
it('stops at 5 rows and says how many groups are left', async () => {
await opened({ preview: manyPreview })
expect(groupRows()).toHaveLength(5)
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-more"]'))).toBe(
'and 3 more groups',
)
expect(buttonLabels()).toEqual(['Cancel', 'Purge 8 groups'])
})
it('says "and 1 more group" for one', async () => {
await opened({ preview: () => ({ ...manyPreview(), more: 1 }) })
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-more"]'))).toBe(
'and 1 more group',
)
})
it('shows Nothing to purge with a single Close button', async () => {
await opened({ preview: emptyPreview })
const box = dialogEl()!
expect(textOf(box)).toContain('Nothing to purge')
expect(textOf(box)).toContain(
'No duplicate is used in an article, so there is nothing to merge.',
)
expect(box.querySelector('svg')).not.toBeNull()
expect(buttonLabels()).toEqual(['Close'])
expect(box.querySelector('[data-testid="purge-summary"]')).toBeNull()
expect(box.querySelector('a')).toBeNull()
findButton('Close', box)!.click()
await flushPromises()
expect(dialogEl()).toBeNull()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
})
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('shows the error notice after %s and offers no purge', async (_name, preview) => {
await opened({ preview })
expect(noticeText()).toBe(FAILED_COPY)
expect(confirmButton('Purge duplicates').disabled).toBe(true)
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(false)
expect(dialogEl()!.querySelector('[data-testid="purge-summary"]')).toBeNull()
confirmButton('Purge duplicates').click()
await flushPromises()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
})
})
describe('Purge dialog: leaving', () => {
it('closes with Cancel, sends nothing and returns focus to the opener', async () => {
const opener = await opened()
findButton('Cancel', dialogEl()!)!.click()
await flushPromises()
expect(dialogEl()).toBeNull()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
expect(document.activeElement).toBe(opener)
})
it('closes with Escape, sends nothing and returns focus to the opener', async () => {
const opener = await opened()
pressEscape(dialogEl())
await flushPromises()
expect(dialogEl()).toBeNull()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
expect(document.activeElement).toBe(opener)
})
it('closes with a click on the overlay and returns focus to the opener', async () => {
const opener = await opened()
const overlay = document.body.querySelector('[data-slot="overlay"]')!
overlay.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await flushPromises()
expect(dialogEl()).toBeNull()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
expect(document.activeElement).toBe(opener)
})
it('follows the review link to the Duplicates tab', async () => {
await opened()
dialogEl()!.querySelector('a')!.click()
await flushPromises()
expect(dialogEl()).toBeNull()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/media'))
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
})
})
describe('Purge dialog: purging', () => {
it('sends exactly the fingerprint of the preview, once', async () => {
await opened({ preview: () => previewOf({ fingerprint: 'ab12' }) })
confirmButton('Purge 4 groups').click()
await flushPromises()
const calls = callsTo('POST', PURGE_PATH)
expect(calls).toHaveLength(1)
expect(bodyOf(calls[0]!)).toEqual({ fingerprint: 'ab12' })
expect(Object.keys(bodyOf(calls[0]!))).toEqual(['fingerprint'])
})
it('cannot be dismissed while the request runs', async () => {
const pending = deferredValue<unknown>()
await opened({ purge: () => pending.promise })
confirmButton('Purge 4 groups').click()
await flushPromises()
const confirm = confirmButton('Purging…')
expect(confirm.disabled).toBe(true)
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(true)
pressEscape(dialogEl())
document.body
.querySelector('[data-slot="overlay"]')
?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
await flushPromises()
expect(dialogEl()).not.toBeNull()
pending.resolve(purgeResult())
await flushPromises()
expect(dialogEl()).toBeNull()
})
it('closes, shows the toast, refreshes Last purge in place and returns focus on success', async () => {
const opener = await opened({ settings: afterRefresh(purgedSettings()) })
expect(lastLine()).toBe(LAST_RUN_LINE)
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(dialogEl()).toBeNull()
expect(toasts.add).toHaveBeenCalledTimes(1)
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Duplicates purged',
description: '9 copies merged. 12 articles updated.',
duration: 5000,
}),
)
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
expect(lastLine()).toBe(AFTER_PURGE_LINE)
expect(textOf(nextLine())).toBe('Next purge: 4 Nov 2026')
expect(document.activeElement).toBe(opener)
})
it('shows no Next purge when automatic purge is off', async () => {
await opened({
settings: afterRefresh(
purgedSettings({ auto_purge_enabled: false, next_purge_at: null }),
),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(lastLine()).toBe(AFTER_PURGE_LINE)
expect(nextLine()).toBeNull()
})
it('pluralises the toast', async () => {
await opened({ purge: () => purgeResult({ merged: 1, articles_updated: 1 }) })
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({ description: '1 copy merged. 1 article updated.' }),
)
})
it('emits purged with the counts and then closes', async () => {
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: vi.fn(async (path: string) =>
path === PREVIEW_PATH ? previewOf() : purgeResult(),
),
})
const wrapper = mount(PurgeDuplicatesDialog, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(wrapper.emitted('purged')).toEqual([[purgeResult()]])
expect(wrapper.emitted('close')).toHaveLength(1)
wrapper.unmount()
})
})
describe('Purge dialog: failures', () => {
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network error', () => Promise.reject(new NetworkError())],
])(
'keeps the dialog open after %s and retries with the same fingerprint',
async (_name, purge) => {
await opened({ preview: () => previewOf({ fingerprint: 'ab12' }), purge })
confirmButton('Purge 4 groups').click()
await flushPromises()
const box = dialogEl()!
const alert = box.querySelector('[role="alert"]')!
expect(textOf(alert)).toBe(FAILED_COPY)
expect(alert.querySelector('svg')).not.toBeNull()
expect(isBefore(alert, box.querySelector('[data-testid="purge-summary"]')!)).toBe(true)
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(SUMMARY)
expect(groupRows()).toHaveLength(4)
expect(findButton('Cancel', box)!.disabled).toBe(false)
expect(confirmButton('Purge 4 groups').disabled).toBe(false)
expect(toasts.add).not.toHaveBeenCalled()
expect(lastLine()).toBe(LAST_RUN_LINE)
confirmButton('Purge 4 groups').click()
await flushPromises()
const calls = callsTo('POST', PURGE_PATH)
expect(calls).toHaveLength(2)
expect(bodyOf(calls[1]!)).toEqual({ fingerprint: 'ab12' })
},
)
it('warns about a partial result, refreshes the preview and the settings, and retries with the new fingerprint', async () => {
let previews = 0
await opened({
settings: afterRefresh(purgedSettings()),
preview: () => (previews++ === 0 ? previewOf() : singlePreview(2, 1, 2)),
purge: () =>
purgeResult({
groups_total: 4,
groups_purged: 3,
merged: 8,
articles_updated: 10,
failed_groups: 1,
}),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
const box = dialogEl()!
expect(noticeText()).toBe(
"Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish.",
)
expect(box.querySelector('[role="alert"] svg')).not.toBeNull()
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(
'1 group · 1 copy will be deleted · 2 articles will be updated',
)
expect(buttonLabels()).toEqual(['Close', 'Retry'])
expect(toasts.add).not.toHaveBeenCalled()
expect(lastLine()).toBe(AFTER_PURGE_LINE)
confirmButton('Retry').click()
await flushPromises()
const calls = callsTo('POST', PURGE_PATH)
expect(calls).toHaveLength(2)
expect(bodyOf(calls[1]!)).toEqual({ fingerprint: 'one' })
})
it('shows Nothing to purge when a partial result leaves no group', async () => {
let previews = 0
await opened({
preview: () => (previews++ === 0 ? previewOf() : emptyPreview()),
purge: () => purgeResult({ groups_purged: 3, failed_groups: 1 }),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(textOf(dialogEl())).toContain('Nothing to purge')
expect(buttonLabels()).toEqual(['Close'])
})
it('refreshes the summary after the library changed and confirms with the new fingerprint', async () => {
let previews = 0
const refreshed = previewOf({
groups: 3,
copies: 7,
articles: 10,
list: previewOf().list.slice(0, 3),
fingerprint: 'cd34',
})
await opened({
preview: () => (previews++ === 0 ? previewOf({ fingerprint: 'ab12' }) : refreshed),
purge: () => Promise.reject(new ApiError(409, undefined, STALE_COPY)),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
const box = dialogEl()!
expect(noticeText()).toBe(STALE_COPY)
expect(box.querySelector('[role="alert"] svg')).not.toBeNull()
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(
'3 groups · 7 copies will be deleted · 10 articles will be updated',
)
expect(buttonLabels()).toEqual(['Cancel', 'Purge 3 groups'])
expect(toasts.add).not.toHaveBeenCalled()
expect(lastLine()).toBe(LAST_RUN_LINE)
confirmButton('Purge 3 groups').click()
await flushPromises()
expect(bodyOf(callsTo('POST', PURGE_PATH)[1]!)).toEqual({ fingerprint: 'cd34' })
})
it('shows the server text when a purge is already running', async () => {
await opened({ purge: () => Promise.reject(new ApiError(409, undefined, BUSY_COPY)) })
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(noticeText()).toBe(BUSY_COPY)
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
expect(dialogEl()).not.toBeNull()
})
})
@@ -15,6 +15,8 @@ import {
stubClipboard,
upToDate,
visibleButtonLabels,
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -41,6 +43,9 @@ async function mountSettings(role: UserRole, status: UpdateStatus) {
if (path === STATUS_PATH) {
return Promise.resolve(status)
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
@@ -0,0 +1,466 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import {
INVALID_DAYS_COPY,
OFFLINE_COPY,
PREVIEW_PATH,
SAVE_FAILED_COPY,
SETTINGS_PATH,
bodyOf,
callsTo,
cardEl,
commitByBlur,
commitByEnter,
daysError,
daysInput,
deferredValue,
enabledSettings,
findButton,
goOffline,
lastLine,
mountSettings,
nextLine,
purgeButton,
purgeKit,
purgeSettings,
saveStatus,
switchEl,
switchError,
textOf,
typeInto,
unmountSettings,
type SettingsCall,
} from './purgeTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const DESCRIPTION =
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.'
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
function patching(onPatch: (call: SettingsCall) => unknown, read = () => enabledSettings()) {
return (call: SettingsCall) => (call.method === 'PATCH' ? onPatch(call) : read())
}
function savedInterval(call: SettingsCall) {
const days = (call.body as { auto_purge_interval_days: number }).auto_purge_interval_days
return enabledSettings({ auto_purge_interval_days: days })
}
beforeEach(() => {
toasts.add.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountSettings)
describe('Media cleanup card: default and on', () => {
it('Default: off, never run', async () => {
await mountSettings({ settings: () => purgeSettings() })
const card = cardEl()
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(1)
expect(textOf(card.querySelector('h2'))).toBe('Media cleanup')
expect(textOf(card)).toContain(DESCRIPTION)
expect(textOf(card)).toContain('Automatic purge')
expect(textOf(card)).toContain('Runs in the background. Off by default.')
expect(switchEl().getAttribute('aria-checked')).toBe('false')
expect(daysInput()).toBeNull()
expect(textOf(card)).not.toContain('Every')
expect(nextLine()).toBeNull()
expect(lastLine()).toBe('Never run')
expect(card.querySelector('[role="separator"]')).not.toBeNull()
expect(textOf(card)).toContain('Purge now')
expect(textOf(card)).toContain(
'Merge every group of duplicates that has an image in use, right away.',
)
expect(purgeButton().disabled).toBe(false)
expect(findButton('Save', card)).toBeUndefined()
})
it('On: 30 days, last run', async () => {
await mountSettings({ settings: () => enabledSettings() })
const card = cardEl()
expect(switchEl().getAttribute('aria-checked')).toBe('true')
expect(textOf(card)).toContain('Every')
expect(textOf(card)).toContain('Between 1 and 365 days.')
expect(daysInput()!.value).toBe('30')
expect(textOf(card)).toContain('days')
expect(lastLine()).toBe('Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated')
expect(textOf(nextLine())).toBe('Next purge: 14 Jul 2026')
})
it.each([
[3, 5, 'Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated'],
[1, 1, 'Last purge: 14 Jun 2026 · 1 copy merged · 1 article updated'],
[0, 0, 'Last purge: 14 Jun 2026 · 0 copies merged · 0 articles updated'],
])(
'pluralises the Last purge line for %i merged and %i articles',
async (merged, articles, line) => {
await mountSettings({
settings: () =>
enabledSettings({
last_purge_merged: merged,
last_purge_articles_updated: articles,
}),
})
expect(lastLine()).toBe(line)
},
)
})
describe('Media cleanup card: the switch', () => {
it('turning it on sends exactly the flag, shows Saving, then Saved for 2 s', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const patch = deferredValue<unknown>()
await mountSettings({
settings: patching(
() => patch.promise,
() => purgeSettings(),
),
})
switchEl().click()
await flushPromises()
const [call] = callsTo('PATCH', SETTINGS_PATH)
expect(call![0]).toBe(SETTINGS_PATH)
expect(bodyOf(call!)).toEqual({ auto_purge_enabled: true })
expect(Object.keys(bodyOf(call!))).toEqual(['auto_purge_enabled'])
expect(saveStatus()).toBe('Saving…')
expect(
cardEl()
.querySelector('[data-testid="cleanup-save-status"]')!
.getAttribute('aria-live'),
).toBe('polite')
expect(switchEl().disabled).toBe(false)
patch.resolve(
enabledSettings({ last_purge_at: null, next_purge_at: '2026-11-04T09:00:00Z' }),
)
await flushPromises()
expect(saveStatus()).toBe('Saved')
expect(daysInput()!.value).toBe('30')
expect(textOf(nextLine())).toBe('Next purge: 4 Nov 2026')
vi.advanceTimersByTime(1999)
await flushPromises()
expect(saveStatus()).toBe('Saved')
vi.advanceTimersByTime(1)
await flushPromises()
expect(saveStatus()).toBe('')
})
it('turning it off sends exactly the flag and hides the schedule but keeps Last purge', async () => {
await mountSettings({
settings: patching(() =>
enabledSettings({ auto_purge_enabled: false, next_purge_at: null }),
),
})
switchEl().click()
await flushPromises()
const [call] = callsTo('PATCH', SETTINGS_PATH)
expect(bodyOf(call!)).toEqual({ auto_purge_enabled: false })
expect(switchEl().getAttribute('aria-checked')).toBe('false')
expect(daysInput()).toBeNull()
expect(nextLine()).toBeNull()
expect(lastLine()).toBe('Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated')
})
})
describe('Media cleanup card: the days field', () => {
it.each([
['45', 'blur', 45],
['45', 'Enter', 45],
['1', 'Enter', 1],
['365', 'blur', 365],
])('saves %s once when it %s commits', async (typed, commit, sent) => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
await typeInto(input, typed)
await (commit === 'blur' ? commitByBlur(input) : commitByEnter(input))
const calls = callsTo('PATCH', SETTINGS_PATH)
expect(calls).toHaveLength(1)
expect(bodyOf(calls[0]!)).toEqual({ auto_purge_interval_days: sent })
expect(saveStatus()).toBe('Saved')
expect(textOf(nextLine())).toBe('Next purge: 14 Jul 2026')
})
it('sends one request when Enter is followed by the blur', async () => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
await typeInto(input, '45')
await commitByEnter(input)
await commitByBlur(input)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(1)
})
it('sends nothing for an unchanged value', async () => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
input.focus()
await commitByBlur(input)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
expect(saveStatus()).toBe('')
})
it.each(['', '0', '366', '400', '1.5'])(
'rejects "%s" without sending anything and keeps the text',
async (typed) => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
await typeInto(input, typed)
await commitByBlur(input)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
expect(input.value).toBe(typed)
expect(input.getAttribute('aria-invalid')).toBe('true')
expect(textOf(daysError())).toBe(INVALID_DAYS_COPY)
expect(daysError()!.querySelector('svg')).not.toBeNull()
expect(saveStatus()).toBe('')
},
)
it('also validates on Enter', async () => {
await mountSettings({ settings: patching(savedInterval) })
await typeInto(daysInput()!, '366')
await commitByEnter(daysInput()!)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
expect(textOf(daysError())).toBe(INVALID_DAYS_COPY)
})
it('clears the validation error once the value is valid and sends it', async () => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
await typeInto(input, '400')
await commitByBlur(input)
expect(daysError()).not.toBeNull()
await typeInto(input, '60')
await commitByEnter(input)
expect(daysError()).toBeNull()
expect(input.getAttribute('aria-invalid')).toBeNull()
expect(bodyOf(callsTo('PATCH', SETTINGS_PATH)[0]!)).toEqual({
auto_purge_interval_days: 60,
})
})
})
describe('Media cleanup card: save failures', () => {
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network error', () => Promise.reject(new NetworkError())],
])('reverts the days field after %s and shows the message under it', async (_name, failure) => {
await mountSettings({ settings: patching(failure) })
const input = daysInput()!
await typeInto(input, '45')
await commitByEnter(input)
expect(input.value).toBe('30')
expect(textOf(daysError())).toBe(SAVE_FAILED_COPY)
expect(saveStatus()).toBe('')
})
it('reverts the switch after a 500 and shows the message under Automatic purge', async () => {
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(500))) })
switchEl().click()
await flushPromises()
expect(switchEl().getAttribute('aria-checked')).toBe('true')
expect(textOf(switchError())).toBe(SAVE_FAILED_COPY)
expect(saveStatus()).toBe('')
})
it('removes the message on the next successful save', async () => {
let failing = true
await mountSettings({
settings: patching((call) =>
failing ? Promise.reject(new ApiError(500)) : savedInterval(call),
),
})
const input = daysInput()!
await typeInto(input, '45')
await commitByEnter(input)
expect(daysError()).not.toBeNull()
failing = false
await typeInto(input, '45')
await commitByEnter(input)
expect(daysError()).toBeNull()
expect(saveStatus()).toBe('Saved')
})
it('reverts silently on a 403, refreshes the session and becomes read-only', async () => {
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(403))) })
const refresh = vi.spyOn(useSessionStore(), 'refresh')
purgeKit.meRole = 'editor'
switchEl().click()
await flushPromises()
expect(refresh).toHaveBeenCalledTimes(1)
expect(toasts.add).not.toHaveBeenCalled()
expect(router.currentRoute.value.path).toBe('/settings')
expect(switchError()).toBeNull()
expect(textOf(cardEl())).toContain('Only owners and admins can change this.')
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
expect(switchEl().disabled).toBe(true)
})
})
describe('Media cleanup card: read-only', () => {
it('never reads the settings and shows placeholders, disabled, with the info alert', async () => {
await mountSettings({ settings: () => enabledSettings() }, { role: 'editor' })
const card = cardEl()
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(0)
expect(switchEl().getAttribute('aria-checked')).toBe('false')
expect(switchEl().disabled).toBe(true)
expect(daysInput()!.value).toBe('30')
expect(daysInput()!.disabled).toBe(true)
expect(lastLine()).toBe('Never run')
expect(nextLine()).toBeNull()
expect(textOf(card.querySelector('[role="status"]'))).toBe(
'Only owners and admins can change this.',
)
expect(card.querySelector('[role="separator"]')).toBeNull()
expect(textOf(card)).not.toContain('Purge now')
expect(findButton('Purge duplicates', card)).toBeUndefined()
switchEl().click()
await commitByBlur(daysInput()!)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
})
it('treats a missing role the same way', async () => {
await mountSettings({}, { role: undefined })
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(0)
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
})
})
describe('Media cleanup card: loading and failure to load', () => {
it('shows the skeleton only after 150 ms, with aria-busy and a disabled button', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const pending = deferredValue<unknown>()
await mountSettings({ settings: () => pending.promise })
vi.advanceTimersByTime(149)
await flushPromises()
expect(cardEl().hasAttribute('aria-busy')).toBe(false)
expect(cardEl().querySelector('.animate-pulse')).toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
expect(cardEl().getAttribute('aria-busy')).toBe('true')
expect(cardEl().querySelector('.animate-pulse')).not.toBeNull()
expect(purgeButton().disabled).toBe(true)
pending.resolve(purgeSettings())
await flushPromises()
expect(cardEl().hasAttribute('aria-busy')).toBe(false)
expect(cardEl().querySelector('.animate-pulse')).toBeNull()
expect(lastLine()).toBe('Never run')
expect(purgeButton().disabled).toBe(false)
})
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('shows the inline alert after %s and Try again loads it again', async (_name, failure) => {
let failing = true
await mountSettings({ settings: () => (failing ? failure() : purgeSettings()) })
const alert = cardEl().querySelector('[role="alert"]')!
expect(textOf(alert)).toContain(UNREACHABLE_COPY)
expect(alert.querySelector('button[aria-label="Close"]')).toBeNull()
expect(document.body.querySelector('[data-testid="settings-version"]')).not.toBeNull()
failing = false
findButton('Try again', cardEl())!.click()
await flushPromises()
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
expect(cardEl().querySelector('[role="alert"]')).toBeNull()
expect(lastLine()).toBe('Never run')
})
})
describe('Media cleanup card: offline and 401', () => {
it('shows the banner and disables the controls, sending nothing', async () => {
await mountSettings({ settings: () => enabledSettings() })
await goOffline()
expect(document.body.textContent).toContain(OFFLINE_COPY)
expect(switchEl().disabled).toBe(true)
expect(daysInput()!.disabled).toBe(true)
expect(purgeButton().disabled).toBe(true)
switchEl().click()
purgeButton().click()
await flushPromises()
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(0)
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
})
it('goes to the login page on a 401 and shows no copy of its own', async () => {
await mountSettings(
{ settings: () => Promise.reject(new ApiError(401)) },
{ role: 'owner', realApi: true },
)
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query).toEqual({
redirect: '/settings',
reason: 'expired',
})
expect(textOf(cardEl())).not.toContain(UNREACHABLE_COPY)
expect(cardEl().querySelector('[role="alert"]')).toBeNull()
})
it('shows no save failure for a 401 on a change', async () => {
await mountSettings(
{ settings: patching(() => Promise.reject(new ApiError(401))) },
{ role: 'owner', realApi: true },
)
switchEl().click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
expect(textOf(cardEl())).not.toContain(SAVE_FAILED_COPY)
})
})
@@ -32,6 +32,8 @@ import {
updateSucceeded,
verifying,
visibleButtonLabels,
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -135,6 +137,9 @@ async function mountSettings(options: MountOptions) {
}
const handler = handlers[path]
if (!handler) {
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
}
return handler()
@@ -262,6 +267,9 @@ function silenceStatusReads() {
if (path === STATUS_PATH) {
return Promise.reject(new NetworkError())
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
}
@@ -650,6 +658,9 @@ describe('Settings, the overlay ends when the site answers', () => {
if (path === STATUS_PATH) {
return answers()
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
siteUp = healthy
@@ -21,6 +21,8 @@ import {
startable,
verifying,
visibleButtonLabels,
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -89,6 +91,9 @@ async function mountSettings(options: MountOptions) {
if (path === DOWNLOAD_PATH) {
return downloadHandler()
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
getMeMock = vi.fn().mockResolvedValue({
@@ -19,6 +19,8 @@ import {
stubClipboard,
upToDate,
visibleButtonLabels,
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -77,6 +79,9 @@ async function mountSettings(options: MountOptions) {
if (path === CHECK_PATH) {
return checkHandler()
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
getMeMock = vi.fn().mockResolvedValue({
@@ -6,6 +6,7 @@ import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import SettingsView from '../SettingsView.vue'
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -38,6 +39,9 @@ async function mountPage(settings: () => Promise<unknown>) {
if (path === SETTINGS_PATH) {
return settings()
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
+16 -2
View File
@@ -236,6 +236,8 @@ export interface Handlers {
hash?: (path: string) => unknown
remove?: (id: number) => unknown
merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown
preview?: () => unknown
purge?: (body: { fingerprint: string }) => unknown
}
export function libraryHandlers(
@@ -274,6 +276,12 @@ function dispatcher(handlers: Handlers) {
if (method === 'GET' && path.startsWith('/v1/media/?')) {
return (handlers.list ?? (() => listBody(fixture())))(path)
}
if (method === 'GET' && path === '/v1/media/purge-preview') {
return (handlers.preview ?? (() => undefined))()
}
if (method === 'POST' && path === '/v1/media/purge') {
return (handlers.purge ?? (() => undefined))(JSON.parse(String(options?.body)))
}
const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path)
if (method === 'GET' && usage) {
return (handlers.usage ?? (() => []))(Number(usage[1]))
@@ -316,12 +324,18 @@ export async function mountPage(
handlers: Handlers = {},
url = '/media',
width = DESKTOP_WIDTH,
options: { realApi?: boolean } = {},
options: { realApi?: boolean; role?: string } = {},
) {
setViewportWidth(width)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: 'owner' }
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: (options.role ?? 'owner') as never,
}
session.status = 'ready'
const dispatch = dispatcher(handlers)
if (options.realApi) {
+351
View File
@@ -0,0 +1,351 @@
import { vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { ApiError, useApi } from '@/composables/useApi'
import type { PurgePreview, PurgeResult, PurgeSettings } from '@/composables/useMediaPurge'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import SettingsView from '../SettingsView.vue'
export const SETTINGS_PATH = '/v1/media/purge-settings'
export const PREVIEW_PATH = '/v1/media/purge-preview'
export const PURGE_PATH = '/v1/media/purge'
export const STALE_COPY =
'The library changed, so nothing was purged. Check the new summary and try again.'
export const BUSY_COPY = 'A purge is already running.'
export const FAILED_COPY =
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
export const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
export const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
export const OFFLINE_COPY =
"You're offline. Changing automatic purge and purging duplicates need a connection."
export function purgeSettings(overrides: Partial<PurgeSettings> = {}): PurgeSettings {
return {
auto_purge_enabled: false,
auto_purge_interval_days: 30,
last_purge_at: null,
last_purge_merged: 0,
last_purge_articles_updated: 0,
enabled_at: null,
next_purge_at: null,
...overrides,
}
}
export function enabledSettings(overrides: Partial<PurgeSettings> = {}) {
return purgeSettings({
auto_purge_enabled: true,
last_purge_at: '2026-06-14T09:00:00Z',
last_purge_merged: 3,
last_purge_articles_updated: 5,
enabled_at: '2026-06-14T09:00:00Z',
next_purge_at: '2026-07-14T09:00:00Z',
...overrides,
})
}
const GROUPS = [
{
keep_id: 12,
filename: 'floorboards.jpg',
url: '/media/floorboards.jpg',
used_in: 4,
merged: 2,
},
{ keep_id: 7, filename: 'IMG_4471.jpg', url: '/media/IMG_4471.jpg', used_in: 2, merged: 1 },
{
keep_id: 2,
filename: 'kitchen-after.webp',
url: '/media/kitchen-after.webp',
used_in: 3,
merged: 3,
},
{ keep_id: 4, filename: 'logo-renew.svg', url: '/media/logo-renew.svg', used_in: 5, merged: 3 },
{
keep_id: 11,
filename: 'lime-plaster.jpg',
url: '/media/lime-plaster.jpg',
used_in: 2,
merged: 1,
},
]
export function previewOf(overrides: Partial<PurgePreview> = {}): PurgePreview {
return {
groups: 4,
copies: 9,
articles: 12,
list: GROUPS.slice(0, 4),
more: 0,
fingerprint: 'ab12',
...overrides,
}
}
export function manyPreview() {
return previewOf({ groups: 8, copies: 15, articles: 24, list: GROUPS, more: 3 })
}
export function emptyPreview() {
return previewOf({ groups: 0, copies: 0, articles: 0, list: [], fingerprint: 'none' })
}
export function singlePreview(usedIn: number, merged: number, articles: number, copies = merged) {
return previewOf({
groups: 1,
copies,
articles,
list: [{ ...GROUPS[0]!, used_in: usedIn, merged }],
fingerprint: 'one',
})
}
export function purgeResult(overrides: Partial<PurgeResult> = {}): PurgeResult {
return {
groups_total: 4,
groups_purged: 4,
merged: 9,
articles_updated: 12,
failed_groups: 0,
...overrides,
}
}
export interface SettingsCall {
method: string
body: unknown
}
export interface PurgeMocks {
settings?: (call: SettingsCall) => unknown
preview?: () => unknown
purge?: (body: { fingerprint: string }) => unknown
}
export const purgeKit = {
requestMock: vi.fn(),
wrapper: null as VueWrapper | null,
meRole: 'owner' as string | undefined,
getMe: vi.fn(),
}
export function installPurgeApi(mocks: PurgeMocks) {
return (path: string, options?: RequestInit) => {
const method = options?.method ?? 'GET'
if (path === SETTINGS_PATH) {
const body = options?.body ? JSON.parse(String(options.body)) : undefined
return (mocks.settings ?? (() => purgeSettings()))({ method, body })
}
if (path === PREVIEW_PATH && method === 'GET') {
return (mocks.preview ?? (() => previewOf()))()
}
if (path === PURGE_PATH && method === 'POST') {
return (mocks.purge ?? (() => purgeResult()))(JSON.parse(String(options?.body)))
}
return undefined
}
}
function fetchFrom(request: (path: string, init?: RequestInit) => Promise<unknown>) {
return vi.fn(async (path: string, init?: RequestInit) => {
const reply = (status: number, body?: unknown) => ({
ok: status >= 200 && status < 300,
status,
headers: new Headers(),
json: async () => body,
text: async () => '',
})
try {
const body = await request(path, init)
return reply(body === undefined ? 204 : 200, body)
} catch (error) {
if (error instanceof ApiError) {
return reply(error.status)
}
throw error
}
})
}
export async function mountSettings(
mocks: PurgeMocks = {},
options: { role?: string | undefined; realApi?: boolean } = { role: 'owner' },
) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
purgeKit.meRole = options.role
const session = useSessionStore()
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: options.role as never,
}
session.status = 'ready'
const dispatch = installPurgeApi(mocks)
purgeKit.requestMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/settings/') {
return { version: 'v2.2.0-beta' }
}
if (path === '/v1/system/update') {
return new Promise(() => undefined)
}
return dispatch(path, init)
})
purgeKit.getMe = vi.fn(async () => ({
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: purgeKit.meRole,
}))
if (options.realApi) {
const actual =
await vi.importActual<typeof import('@/composables/useApi')>('@/composables/useApi')
vi.mocked(useApi).mockImplementation(actual.useApi)
vi.stubGlobal('fetch', fetchFrom(purgeKit.requestMock))
} else {
vi.mocked(useApi).mockReturnValue({
getMe: purgeKit.getMe,
request: purgeKit.requestMock,
})
}
await router.push('/settings')
purgeKit.wrapper = mount(SettingsView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return purgeKit.wrapper
}
export async function unmountSettings() {
purgeKit.wrapper?.unmount()
purgeKit.wrapper = null
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
await router.push('/login')
document.body.innerHTML = ''
}
export function cardEl() {
return document.body.querySelector<HTMLElement>('[data-testid="media-cleanup-card"]')!
}
export function textOf(element: Element | null | undefined) {
return (element?.textContent ?? '').replace(/\s+/g, ' ').trim()
}
export function findButton(label: string, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => textOf(button) === label,
)
}
export function purgeButton() {
return findButton('Purge duplicates', cardEl())!
}
export function switchEl() {
return cardEl().querySelector<HTMLButtonElement>('[role="switch"]')!
}
export function daysInput() {
return cardEl().querySelector<HTMLInputElement>('#auto-purge-days')
}
export function daysError() {
return cardEl().querySelector('[data-testid="cleanup-days-error"]')
}
export function switchError() {
return cardEl().querySelector('[data-testid="cleanup-switch-error"]')
}
export function saveStatus() {
return textOf(cardEl().querySelector('[data-testid="cleanup-save-status"]'))
}
export function lastLine() {
return textOf(cardEl().querySelector('[data-testid="cleanup-last"]'))
}
export function nextLine() {
return cardEl().querySelector('[data-testid="cleanup-next"]')
}
export function dialogEl() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
}
export function callsTo(method: string, path: string) {
return purgeKit.requestMock.mock.calls.filter(
([called, options]) => called === path && (options?.method ?? 'GET') === method,
)
}
export function bodyOf(call: unknown[]) {
return JSON.parse(String((call[1] as RequestInit).body))
}
export async function typeInto(input: HTMLInputElement, value: string) {
input.focus()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
export async function commitByBlur(input: HTMLInputElement) {
input.blur()
await flushPromises()
}
export async function commitByEnter(input: HTMLInputElement) {
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await flushPromises()
}
export async function goOffline() {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
}
export function pressEscape(target: Element | null) {
;(target ?? document.body).dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
}
export async function openDialog() {
const opener = purgeButton()
opener.focus()
opener.click()
await flushPromises()
return opener
}
export function isBefore(first: Element, second: Element) {
return first.compareDocumentPosition(second) === Node.DOCUMENT_POSITION_FOLLOWING
}
export function groupRows() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="purge-group"]'))
}
export function deferredValue<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
+12
View File
@@ -134,3 +134,15 @@ export function plainRestartDone(): UpdateStatus {
finished_at: RESTART_FINISHED_AT,
})
}
export const PURGE_SETTINGS_PATH = '/v1/media/purge-settings'
export const DEFAULT_PURGE_SETTINGS = {
auto_purge_enabled: false,
auto_purge_interval_days: 30,
last_purge_at: null,
last_purge_merged: 0,
last_purge_articles_updated: 0,
enabled_at: null,
next_purge_at: null,
}
+164 -69
View File
@@ -11,11 +11,80 @@ import { visualStates, type ApiMock, type VisualState } from './states'
type Viewport = 'desktop' | 'mobile'
type Theme = 'light' | 'dark'
interface Source {
board: string
group: string
states: string[]
alias: Record<string, string>
}
interface Screen {
name: string
board: string
group: string
states: string[]
sources: Source[]
}
const cleanupBoard = 'RenewCMS Screens Media cleanup.dc.html'
const extraSources: Record<string, Source[]> = {
settings: [
{
board: cleanupBoard,
group: 'Settings',
states: [
'Default: off, never run',
'On: 30 days, last run',
'Saving',
'Saved',
'Save failed',
'Validation error',
'Read-only (non-admin)',
'Loading skeleton',
'Offline',
],
alias: {},
},
{
board: cleanupBoard,
group: 'Dialog',
states: [
'Loading preview',
'Confirm',
'Confirm: many groups',
'In progress',
'Nothing to purge',
'Error',
'Partial failure',
'Conflict (409)',
],
alias: {},
},
{
board: cleanupBoard,
group: 'Results',
states: ['Success', 'Success, automatic purge on'],
alias: {},
},
],
media: [
{
board: cleanupBoard,
group: 'Media',
states: [
'Duplicates tab with the button',
'Dialog from Media',
'Member: button hidden',
'Offline: button disabled',
],
alias: { 'Offline: button disabled': 'Offline' },
},
],
}
const boardAlias: Record<string, Record<string, string>> = {
media: { 'Duplicates: purge all': 'Purge duplicates: confirm' },
}
const webRoot = resolve(import.meta.dirname, '..')
@@ -95,25 +164,42 @@ async function readScreen(name: string): Promise<Screen> {
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
}
const statesStart = markdown.search(/^## States$/m)
const statesMarkdown = statesStart === -1 ? markdown : markdown.slice(statesStart)
const afterStates = statesStart === -1 ? markdown : markdown.slice(statesStart)
const nextSection = afterStates.slice(1).search(/^## /m)
const statesMarkdown = nextSection === -1 ? afterStates : afterStates.slice(0, nextSection + 1)
const headingStates = [...statesMarkdown.matchAll(/^### (.+)$/gm)].map((match) =>
match[1]!.trim(),
)
const states = headingStates.length ? headingStates : tableStates(statesMarkdown)
return { name, board: reference[1]!, group: reference[2]!, states }
const primary: Source = {
board: reference[1]!,
group: reference[2]!,
states,
alias: boardAlias[name] ?? {},
}
return {
name,
board: primary.board,
group: primary.group,
states,
sources: [primary, ...(extraSources[name] ?? [])],
}
}
function pickStates(screen: Screen, wanted?: string) {
function pickSources(screen: Screen, wanted?: string) {
if (!wanted) {
return screen.states
return screen.sources
}
const match = screen.states.find(
(state) => state.toLowerCase() === wanted.toLowerCase() || slug(state) === slug(wanted),
)
if (!match) {
throw new Error(`Unknown state "${wanted}". Known: ${screen.states.join(', ')}`)
const matches = (state: string) =>
state.toLowerCase() === wanted.toLowerCase() || slug(state) === slug(wanted)
const picked = screen.sources
.map((source) => ({ ...source, states: source.states.filter(matches) }))
.filter((source) => source.states.length > 0)
if (picked.length === 0) {
const known = screen.sources.flatMap((source) => source.states)
throw new Error(`Unknown state "${wanted}". Known: ${known.join(', ')}`)
}
return [match]
return picked
}
async function serveDesign() {
@@ -444,7 +530,7 @@ async function diff(browser: Browser, designPath: string, appPath: string, diffP
async function main() {
const args = parseArgs(process.argv.slice(2))
const screen = await readScreen(args.screen)
const states = pickStates(screen, args.state)
const sources = pickSources(screen, args.state)
const setups = visualStates[screen.name] ?? {}
const design = await serveDesign()
@@ -458,67 +544,76 @@ async function main() {
try {
for (const viewport of args.viewports) {
for (const theme of args.themes) {
const board = await openBoard(browser, design.origin, screen, theme)
for (const state of states) {
const skip = setups[state]?.skip
if (skip === 'pending') {
pending++
lines.push(`PENDING ${state} · ${viewport} · ${theme}: slice B2`)
continue
}
if (skip) {
skipped++
lines.push(`SKIP ${state} · ${viewport} · ${theme}: ${skip}`)
continue
}
const directory = join(outRoot, screen.name, slug(state))
await mkdir(directory, { recursive: true })
const suffix = `${viewport}-${theme}.png`
const designPath = join(directory, `design-${suffix}`)
await captureDesign(board, screen, state, viewport, designPath)
const setup = setups[state]
if (!setup) {
failures++
lines.push(
`FAIL ${state} · ${viewport} · ${theme}: no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
for (const source of sources) {
const sourceScreen = { ...screen, board: source.board, group: source.group }
const board = await openBoard(browser, design.origin, sourceScreen, theme)
for (const state of source.states) {
const skip = setups[state]?.skip
if (skip === 'pending') {
pending++
lines.push(`PENDING ${state} · ${viewport} · ${theme}: slice B2`)
continue
}
if (skip) {
skipped++
lines.push(`SKIP ${state} · ${viewport} · ${theme}: ${skip}`)
continue
}
const directory = join(outRoot, screen.name, slug(state))
await mkdir(directory, { recursive: true })
const suffix = `${viewport}-${theme}.png`
const designPath = join(directory, `design-${suffix}`)
await captureDesign(
board,
sourceScreen,
source.alias[state] ?? state,
viewport,
designPath,
)
continue
}
const appPath = join(directory, `app-${suffix}`)
const diffPath = join(directory, `diff-${suffix}`)
const unmocked = await captureApp(
browser,
app.origin,
setup,
viewport,
theme,
appPath,
)
const result = await diff(browser, designPath, appPath, diffPath)
const percent = result.ratio * 100
const budget = setup.exception?.maxPercent ?? maxDiffPercent
const problems = [
...(percent > budget
? [`${percent.toFixed(2)}% exceeds ${budget}% budget`]
: []),
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
? [`size mismatch ${result.sizes}`]
: []),
...unmocked.map((key) => `unmocked ${key}`),
]
if (problems.length) {
failures++
const setup = setups[state]
if (!setup) {
failures++
lines.push(
`FAIL ${state} · ${viewport} · ${theme}: no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
)
continue
}
const appPath = join(directory, `app-${suffix}`)
const diffPath = join(directory, `diff-${suffix}`)
const unmocked = await captureApp(
browser,
app.origin,
setup,
viewport,
theme,
appPath,
)
const result = await diff(browser, designPath, appPath, diffPath)
const percent = result.ratio * 100
const budget = setup.exception?.maxPercent ?? maxDiffPercent
const problems = [
...(percent > budget
? [`${percent.toFixed(2)}% exceeds ${budget}% budget`]
: []),
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
? [`size mismatch ${result.sizes}`]
: []),
...unmocked.map((key) => `unmocked ${key}`),
]
if (problems.length) {
failures++
}
const exception = setup.exception
? ` [exception ${setup.exception.maxPercent}% approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
: ''
lines.push(
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
)
}
const exception = setup.exception
? ` [exception ${setup.exception.maxPercent}% approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
: ''
lines.push(
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
)
await board.context().close()
}
await board.context().close()
}
}
} finally {
@@ -535,7 +630,7 @@ async function main() {
: `PASS: all ${checked} captures within budget (${maxDiffPercent}% unless an approved exception is listed${skippedNote})`
const report = [
`# Visual check: ${screen.name}`,
`Board: ${screen.board}, group "${screen.group}"`,
`Boards: ${sources.map((source) => `${source.board} / ${source.group}`).join('; ')}`,
'',
verdict,
'',
+356 -1
View File
@@ -2246,6 +2246,336 @@ const apiKeysStates: Record<string, VisualState> = {
},
}
const purgeSettingsPath = 'GET /v1/media/purge-settings'
const purgePatchPath = 'PATCH /v1/media/purge-settings'
const purgePreviewPath = 'GET /v1/media/purge-preview'
const purgeRunPath = 'POST /v1/media/purge'
const purgeStaleCopy =
'The library changed, so nothing was purged. Check the new summary and try again.'
function purgeSettingsBody(overrides: Record<string, unknown> = {}) {
return {
auto_purge_enabled: false,
auto_purge_interval_days: 30,
last_purge_at: null,
last_purge_merged: 0,
last_purge_articles_updated: 0,
enabled_at: null,
next_purge_at: null,
...overrides,
}
}
const purgeLastRun = {
last_purge_at: '2026-06-14T09:00:00Z',
last_purge_merged: 3,
last_purge_articles_updated: 5,
}
const purgeOnBody = purgeSettingsBody({
...purgeLastRun,
auto_purge_enabled: true,
enabled_at: '2026-06-14T09:00:00Z',
next_purge_at: '2026-07-14T09:00:00Z',
})
const purgeDoneLast = {
last_purge_at: '2026-10-05T09:00:00Z',
last_purge_merged: 9,
last_purge_articles_updated: 12,
}
const purgeGroups = [
{
keep_id: 12,
filename: 'floorboards.jpg',
url: '/media/floorboards.jpg',
used_in: 4,
merged: 2,
},
{ keep_id: 7, filename: 'IMG_4471.jpg', url: '/media/IMG_4471.jpg', used_in: 2, merged: 1 },
{
keep_id: 2,
filename: 'kitchen-after.webp',
url: '/media/kitchen-after.webp',
used_in: 3,
merged: 3,
},
{ keep_id: 4, filename: 'logo-renew.svg', url: '/media/logo-renew.svg', used_in: 5, merged: 3 },
{
keep_id: 11,
filename: 'lime-plaster.jpg',
url: '/media/lime-plaster.jpg',
used_in: 2,
merged: 1,
},
]
function purgePreviewBody(overrides: Record<string, unknown> = {}) {
return {
groups: 4,
copies: 9,
articles: 12,
list: purgeGroups.slice(0, 4),
more: 0,
fingerprint: 'ab12',
...overrides,
}
}
const purgePreviewMany = purgePreviewBody({
groups: 8,
copies: 15,
articles: 24,
list: purgeGroups,
more: 3,
})
const purgePreviewOne = purgePreviewBody({
groups: 1,
copies: 1,
articles: 2,
list: [purgeGroups[1]],
fingerprint: 'one',
})
const purgePreviewThree = purgePreviewBody({
groups: 3,
copies: 7,
articles: 10,
list: [purgeGroups[0], purgeGroups[2], purgeGroups[3]],
fingerprint: 'cd34',
})
const purgePreviewNone = purgePreviewBody({
groups: 0,
copies: 0,
articles: 0,
list: [],
fingerprint: 'none',
})
const purgeRunPartial = {
groups_total: 4,
groups_purged: 3,
merged: 8,
articles_updated: 10,
failed_groups: 1,
}
const purgeRunDone = {
groups_total: 4,
groups_purged: 4,
merged: 9,
articles_updated: 12,
failed_groups: 0,
}
function sessionWithRole(role: string): ApiMock {
return { status: 200, body: { ...(verifiedSession.body as object), role } }
}
function cleanupApi(
settings: ApiMock,
overrides: Record<string, ApiMock> = {},
role = 'owner',
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': sessionWithRole(role),
'GET /v1/settings/': { status: 200, body: { version: updateRunningVersion } },
'GET /v1/system/update': { status: 200, hang: true },
[purgeSettingsPath]: settings,
...overrides,
}
}
function settingsBody(body: unknown): ApiMock {
return { status: 200, body }
}
function cleanupState(
api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
): VisualState {
return { path: '/settings', api, steps }
}
async function openCleanupDialog(page: Page) {
await page.getByRole('button', { name: 'Purge duplicates', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function confirmCleanupDialog(label: string, wait?: string) {
return async (page: Page) => {
await openCleanupDialog(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: label, exact: true })
.click()
if (wait) {
await page.getByText(wait, { exact: true }).first().waitFor()
}
await page.mouse.move(0, 0)
}
}
async function typeDays(page: Page, value: string) {
const field = page.locator('#auto-purge-days')
await field.fill(value)
await field.press('Enter')
await page.mouse.move(0, 0)
}
function afterFirst(first: unknown, later: unknown): ApiMock {
return { status: 200, bodyFor: (_url, call) => (call === 1 ? first : later) }
}
function cleanupDialogApi(overrides: Record<string, ApiMock> = {}) {
return cleanupApi(settingsBody(purgeSettingsBody()), {
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
...overrides,
})
}
async function holdToast(page: Page) {
await page.getByText('Duplicates purged', { exact: true }).hover()
}
function successState(settings: ApiMock) {
return cleanupState(
cleanupApi(settings, {
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
[purgeRunPath]: settingsBody(purgeRunDone),
}),
async (page) => {
await confirmCleanupDialog('Purge 4 groups', 'Duplicates purged')(page)
await holdToast(page)
},
)
}
const cleanupStates: Record<string, VisualState> = {
'Default: off, never run': cleanupState(cleanupApi(settingsBody(purgeSettingsBody()))),
'On: 30 days, last run': cleanupState(cleanupApi(settingsBody(purgeOnBody))),
Saving: cleanupState(
cleanupApi(settingsBody(purgeSettingsBody(purgeLastRun)), {
[purgePatchPath]: { status: 200, hang: true },
}),
async (page) => {
await page.getByRole('switch').click()
await page.locator('#auto-purge-days').focus()
await page.mouse.move(0, 0)
},
),
Saved: cleanupState(
cleanupApi(settingsBody(purgeSettingsBody(purgeLastRun)), {
[purgePatchPath]: settingsBody(purgeOnBody),
}),
async (page) => {
await page.getByRole('switch').click()
await page.getByText('Saved', { exact: true }).waitFor()
await page.mouse.move(0, 0)
},
),
'Save failed': cleanupState(
cleanupApi(settingsBody(purgeOnBody), { [purgePatchPath]: { status: 500 } }),
async (page) => {
await typeDays(page, '45')
await page.getByText("Couldn't save. Try again in a moment.").waitFor()
},
),
'Validation error': cleanupState(cleanupApi(settingsBody(purgeOnBody)), async (page) => {
await typeDays(page, '400')
await page.getByText('Enter a whole number of days between 1 and 365.').waitFor()
}),
'Read-only (non-admin)': cleanupState(cleanupApi(settingsBody(purgeOnBody), {}, 'member')),
'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true })),
Offline: cleanupState(cleanupApi(settingsBody(purgeOnBody)), goOffline),
'Loading preview': cleanupState(
cleanupDialogApi({ [purgePreviewPath]: { status: 200, hang: true } }),
openCleanupDialog,
),
Confirm: cleanupState(cleanupDialogApi(), openCleanupDialog),
'Confirm: many groups': cleanupState(
cleanupDialogApi({ [purgePreviewPath]: { status: 200, body: purgePreviewMany } }),
openCleanupDialog,
),
'In progress': cleanupState(
cleanupDialogApi({ [purgeRunPath]: { status: 200, hang: true } }),
confirmCleanupDialog('Purge 4 groups'),
),
'Nothing to purge': cleanupState(
cleanupDialogApi({ [purgePreviewPath]: { status: 200, body: purgePreviewNone } }),
openCleanupDialog,
),
Error: cleanupState(
cleanupDialogApi({ [purgeRunPath]: { status: 500 } }),
confirmCleanupDialog(
'Purge 4 groups',
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment.",
),
),
'Partial failure': cleanupState(
cleanupDialogApi({
[purgePreviewPath]: afterFirst(purgePreviewBody(), purgePreviewOne),
[purgeRunPath]: settingsBody(purgeRunPartial),
}),
confirmCleanupDialog(
'Purge 4 groups',
"Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish.",
),
),
'Conflict (409)': cleanupState(
cleanupDialogApi({
[purgePreviewPath]: afterFirst(purgePreviewBody(), purgePreviewThree),
[purgeRunPath]: { status: 409, body: purgeStaleCopy },
}),
confirmCleanupDialog('Purge 4 groups', purgeStaleCopy),
),
Success: successState(afterFirst(purgeSettingsBody(), purgeSettingsBody(purgeDoneLast))),
'Success, automatic purge on': successState(
afterFirst(
purgeOnBody,
purgeSettingsBody({
...purgeDoneLast,
auto_purge_enabled: true,
next_purge_at: '2026-11-04T09:00:00Z',
}),
),
),
}
async function openMediaPurgeDialog(page: Page) {
await page.getByRole('button', { name: 'Purge duplicates', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
const mediaPurgeStates: Record<string, VisualState> = {
'Duplicates: purge all': mediaState(
mediaDuplicatesApi(undefined, {
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
}),
openMediaPurgeDialog,
mediaDuplicatesPath,
),
'Duplicates tab with the button': mediaState(
mediaDuplicatesApi(),
undefined,
mediaDuplicatesPath,
),
'Dialog from Media': mediaState(
mediaDuplicatesApi(undefined, {
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
}),
openMediaPurgeDialog,
mediaDuplicatesPath,
),
'Member: button hidden': mediaState(
mediaDuplicatesApi(undefined, { 'GET /v1/auth/me': sessionWithRole('member') }),
undefined,
mediaDuplicatesPath,
),
'Offline: button disabled': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath),
}
export const visualStates: Record<string, Record<string, VisualState>> = {
profile: {
'Account menu': {
@@ -2391,6 +2721,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
},
settings: {
...cleanupStates,
'Up to date': { path: '/settings', skip: 'clock-sensitive' },
Checking: {
path: '/settings',
@@ -3364,6 +3695,30 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: submitAcceptInvite('lea', 'limewash2026'),
},
},
media: mediaStates,
media: { ...mediaStates, ...mediaPurgeStates },
'media-picker': pickerStates,
}
const hideCleanupCard = '[data-testid="media-cleanup-card"] { display: none !important }'
function withoutCleanupCard(state: VisualState): VisualState {
if (state.skip) {
return state
}
return {
...state,
api: { ...state.api, [purgeSettingsPath]: { status: 200, hang: true } },
steps: async (page) => {
await page.addStyleTag({ content: hideCleanupCard })
await state.steps?.(page)
await page.addStyleTag({ content: hideCleanupCard })
await page.mouse.move(0, 0)
},
}
}
for (const [name, state] of Object.entries(visualStates.settings!)) {
if (!(name in cleanupStates)) {
visualStates.settings![name] = withoutCleanupCard(state)
}
}