From 792f67ed93a5a608e387499df388b484918e9d0c Mon Sep 17 00:00:00 2001
From: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com>
Date: Sat, 5 Sep 2026 13:12:30 +0200
Subject: [PATCH 1/7] fix(media): confirm before permanently deleting an asset
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Purging is the only media action with no undo. It removes the original
binary AND every generated size from the storage adapter — `handleMediaItem`
sweeps `existing.variants` alongside the original — and it fired on a single
unguarded click.
The button sits in the trash preview beside Restore, same row, same size,
one colour apart, so the two read as a pair of equally reversible choices.
The same applies to the trash view's right-click Delete.
Both paths now confirm, naming the file and saying what goes with it. Two
details worth flagging for review:
`alwaysConfirm` on the context-menu path. The `confirmBeforeDelete`
preference defaults off, and someone who turned it off was opting out of
confirming a TRASH — a reversible move — not a purge. Without the flag this
change would be a no-op for exactly the operators most likely to hit it.
A local `Dialog` rather than `useConfirmDelete` in the viewer window. That
hook falls back to running `commit()` immediately when no provider is
mounted (confirmDeleteHook.ts:53-56), and this window also renders from the
dashboard media widget, which mounts none. Routing it through the hook would
have left one surface unguarded while reading as covered on all five.
Also worth a maintainer's eye: `capabilityMeta.ts:87-90` tells operators that
hard-purge "also requires step-up", but the route only calls
`requireCapability(req, db, 'media.delete')` — no `requireStepUp`, unlike
`data/tables.ts:169`. Left alone here because a server-side gate is a
behaviour change that deserves its own PR, but the docs and the code
currently disagree.
Four tests pin the behaviour: no purge on first click, the dialog names the
asset, Cancel is inert, and the second click is what commits.
Co-Authored-By: Claude Opus 5
---
.../media/purgeConfirmation.test.tsx | 122 ++++++++++++++++++
.../components/MediaCanvas/MediaCanvas.tsx | 21 ++-
.../MediaViewerWindow/MediaViewerWindow.tsx | 38 +++++-
3 files changed, 178 insertions(+), 3 deletions(-)
create mode 100644 src/__tests__/media/purgeConfirmation.test.tsx
diff --git a/src/__tests__/media/purgeConfirmation.test.tsx b/src/__tests__/media/purgeConfirmation.test.tsx
new file mode 100644
index 000000000..fb49dbdf5
--- /dev/null
+++ b/src/__tests__/media/purgeConfirmation.test.tsx
@@ -0,0 +1,122 @@
+/**
+ * Permanent deletion of a media asset has to ask first.
+ *
+ * "Delete permanently" removes the original binary AND every generated size
+ * from the storage adapter — not just the row. It is the only media action
+ * with no undo, and it sat one unguarded click away inside the trash preview,
+ * beside the Restore button it visually matches.
+ *
+ * These tests pin the confirmation itself rather than the wording: that the
+ * mutation does NOT fire on the first click, that Cancel leaves the asset
+ * alone, and that the second click is what commits.
+ *
+ * The dialog is local to the window on purpose. `useConfirmDelete` falls back
+ * to running `commit()` immediately when no `ConfirmDeleteProvider` is mounted
+ * (confirmDeleteHook.ts), and this window also renders from the dashboard
+ * media widget, which mounts none — so routing through that hook would have
+ * left one surface silently unguarded while looking covered everywhere.
+ */
+
+import { afterEach, describe, expect, it } from 'bun:test'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { MediaViewerWindow } from '@admin/pages/media/components/MediaViewerWindow/MediaViewerWindow'
+import { AdminSessionContext } from '@admin/sessionContext'
+
+afterEach(cleanup)
+
+/** A trashed asset — the only state in which the purge button renders. */
+function trashedAsset() {
+ return {
+ id: 'asset_1',
+ filename: 'logo.png',
+ mimeType: 'image/png',
+ sizeBytes: 1200,
+ publicPath: '/uploads/logo.png',
+ uploadedByUserId: null,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ altText: '',
+ caption: '',
+ title: '',
+ tags: [],
+ width: null,
+ height: null,
+ durationMs: null,
+ dominantColor: null,
+ deletedAt: '2026-02-01T00:00:00.000Z',
+ replacedAt: null,
+ folderIds: [],
+ blurHash: null,
+ variants: [],
+ posterPath: null,
+ }
+}
+
+function renderViewer() {
+ const purged: string[] = []
+ const editor = {
+ asset: trashedAsset(),
+ tagPalette: [],
+ folderById: new Map(),
+ updateAsset: async () => undefined,
+ renameAsset: async () => undefined,
+ replaceAssetFile: async () => undefined,
+ restoreAsset: async () => undefined,
+ purgeAsset: async (id: string) => {
+ purged.push(id)
+ },
+ }
+ // `media.delete` is what renders the purge button at all.
+ const session = {
+ user: { id: 'u1', capabilities: ['media.delete', 'media.write'] },
+ setUser: () => {},
+ }
+ render(
+
+ [0]['editor']}
+ open
+ onClose={() => {}}
+ />
+ ,
+ )
+ return { purged }
+}
+
+/** The button in the sidebar, not the one inside the dialog footer. */
+function clickPurgeButton() {
+ const buttons = screen.getAllByRole('button', { name: /delete permanently/i })
+ fireEvent.click(buttons[0]!)
+}
+
+describe('permanent media deletion asks first', () => {
+ it('does not purge on the first click', () => {
+ const { purged } = renderViewer()
+ clickPurgeButton()
+ expect(purged).toEqual([])
+ })
+
+ it('names the asset so the operator can see what they are about to lose', () => {
+ renderViewer()
+ clickPurgeButton()
+ // The filename also appears in the window chrome, so match the dialog's
+ // own heading rather than any occurrence of it.
+ expect(screen.getByText('Delete "logo.png" permanently?')).toBeTruthy()
+ })
+
+ it('cancelling leaves the asset alone', () => {
+ const { purged } = renderViewer()
+ clickPurgeButton()
+ fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
+ expect(purged).toEqual([])
+ })
+
+ it('confirming is what commits the purge', () => {
+ const { purged } = renderViewer()
+ clickPurgeButton()
+ // The footer button — the last match, since the sidebar button is still
+ // mounted behind the dialog.
+ const buttons = screen.getAllByRole('button', { name: /delete permanently/i })
+ fireEvent.click(buttons[buttons.length - 1]!)
+ expect(purged).toEqual(['asset_1'])
+ })
+})
diff --git a/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx b/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx
index 3dba08963..ef314bdad 100644
--- a/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx
+++ b/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx
@@ -19,6 +19,7 @@ import { FilterBar, type FilterBarItem } from '@ui/components/FilterBar'
import { Select } from '@ui/components/Select'
import { Skeleton } from '@ui/components/Skeleton'
import { canDeleteMedia, canWriteMedia } from '@admin/access'
+import { useConfirmDelete } from '@admin/shared/dialogs/ConfirmDeleteDialog'
import { useCurrentAdminUser } from '@admin/sessionContext'
import {
ExplorerItemContextMenu,
@@ -122,6 +123,7 @@ function folderMatchesQuery(folder: CmsMediaFolder, query: string): boolean {
export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanvasProps) {
const currentUser = useCurrentAdminUser()
+ const confirmDelete = useConfirmDelete()
const [viewMode, setViewModeState] = useState(readStoredMediaViewMode)
const [contextMenu, setContextMenu] = useState(null)
const [renameTarget, setRenameTarget] = useState(null)
@@ -530,8 +532,23 @@ export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanv
onDelete={() => {
const target = contextMenu.asset
setContextMenu(null)
- if (trashView) void workspace.purgeAsset(target.id)
- else void workspace.trashAsset(target.id)
+ if (!trashView) {
+ void workspace.trashAsset(target.id)
+ return
+ }
+ // Purging is the one media action nothing can undo: it removes the
+ // original and every generated size from the storage adapter, not
+ // just the row. `alwaysConfirm` because the `confirmBeforeDelete`
+ // preference defaults off, and an operator who turned it off was
+ // opting out of confirming a TRASH, which is reversible.
+ confirmDelete({
+ title: `Delete "${target.filename}" permanently?`,
+ description:
+ 'This removes the file and every generated size from disk. It cannot be undone.',
+ confirmLabel: 'Delete permanently',
+ alwaysConfirm: true,
+ commit: () => void workspace.purgeAsset(target.id),
+ })
}}
showRename={canWrite}
showDelete={canDelete}
diff --git a/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx b/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx
index 27b3a6a3b..726b0f7ae 100644
--- a/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx
+++ b/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx
@@ -27,6 +27,7 @@
import { useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Button } from '@ui/components/Button'
+import { Dialog } from '@ui/components/Dialog'
import { Input, Textarea } from '@ui/components/Input'
import { canDeleteMedia, canReplaceMedia, canWriteMedia } from '@admin/access'
import { useCurrentAdminUser } from '@admin/sessionContext'
@@ -101,6 +102,7 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {
const currentUser = useCurrentAdminUser()
const { asset } = editor
const [replaceOpen, setReplaceOpen] = useState(false)
+ const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false)
const bucket = bucketForMime(asset.mimeType)
const canWrite = canWriteMedia(currentUser)
const canReplace = canReplaceMedia(currentUser)
@@ -328,7 +330,7 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {
}
+ onCancel={() => {}}
+ onConfirm={() => {}}
+ />,
+ )
+ expect(screen.getByText('1 of 3 is still in use:')).toBeTruthy()
+ })
+})
+
+describe('resolveUsageWarning', () => {
+ it('never rejects, so the dialog it precedes always opens', async () => {
+ // Every surface opens its dialog from a floating `void (async () => …)()`.
+ // A rejection there is dropped silently and the dialog never appears — a
+ // delete button that does nothing at all.
+ const { resolveUsageWarning } = await import('@admin/pages/media/utils/usageWarning')
+ const warning = await resolveUsageWarning(async () => {
+ throw new Error('network')
+ }, ['a1'])
+ expect(warning).toBeNull()
+ })
+
+ it('does not ask the server about an empty selection', async () => {
+ const { resolveUsageWarning } = await import('@admin/pages/media/utils/usageWarning')
+ let called = false
+ const warning = await resolveUsageWarning(async () => {
+ called = true
+ return []
+ }, [])
+ expect(called).toBe(false)
+ expect(warning).toBeNull()
+ })
+})
diff --git a/src/admin/pages/media/MediaPage.tsx b/src/admin/pages/media/MediaPage.tsx
index f81e3aa29..8b2246188 100644
--- a/src/admin/pages/media/MediaPage.tsx
+++ b/src/admin/pages/media/MediaPage.tsx
@@ -65,6 +65,7 @@ export function MediaPage() {
replaceAssetFile: workspace.replaceAssetFile,
restoreAsset: workspace.restoreAsset,
purgeAsset: workspace.purgeAsset,
+ lookupUsage: workspace.lookupUsage,
}
: null
diff --git a/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx b/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx
index ef314bdad..fe200e671 100644
--- a/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx
+++ b/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx
@@ -54,6 +54,8 @@ import {
writeMediaFolderDragData,
} from '../../utils/mediaDragDrop'
import { useMediaDnd } from '../../hooks/useMediaDnd'
+import { UsageWarningNotice } from '../UsageWarningNotice'
+import { resolveUsageWarning } from '../../utils/usageWarning'
import styles from './MediaCanvas.module.css'
import {
AssetRow,
@@ -541,14 +543,21 @@ export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanv
// just the row. `alwaysConfirm` because the `confirmBeforeDelete`
// preference defaults off, and an operator who turned it off was
// opting out of confirming a TRASH, which is reversible.
- confirmDelete({
- title: `Delete "${target.filename}" permanently?`,
- description:
- 'This removes the file and every generated size from disk. It cannot be undone.',
- confirmLabel: 'Delete permanently',
- alwaysConfirm: true,
- commit: () => void workspace.purgeAsset(target.id),
- })
+ //
+ // The usage lookup is awaited BEFORE the dialog opens so the
+ // warning is on screen when the operator reads it, not after.
+ void (async () => {
+ const warning = await resolveUsageWarning(workspace.lookupUsage, [target.id])
+ confirmDelete({
+ title: `Delete "${target.filename}" permanently?`,
+ description:
+ 'This removes the file and every generated size from disk. It cannot be undone.',
+ details: ,
+ confirmLabel: 'Delete permanently',
+ alwaysConfirm: true,
+ commit: () => void workspace.purgeAsset(target.id),
+ })
+ })()
}}
showRename={canWrite}
showDelete={canDelete}
diff --git a/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx b/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx
index 726b0f7ae..6bd340ff1 100644
--- a/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx
+++ b/src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx
@@ -38,7 +38,14 @@ import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid'
import { VideoSolidIcon } from 'pixel-art-icons/icons/video-solid'
import { PanelHeader } from '@admin/shared/PanelHeader'
import { useDraggablePanel } from '@admin/shared/FloatingWindow'
-import type { CmsMediaAsset, CmsMediaFolder, UpdateCmsMediaAssetInput } from '@core/persistence/cmsMedia'
+import type {
+ CmsMediaAsset,
+ CmsMediaFolder,
+ CmsMediaUsageRef,
+ UpdateCmsMediaAssetInput,
+} from '@core/persistence/cmsMedia'
+import { resolveUsageWarning, type UsageWarning } from '../../utils/usageWarning'
+import { UsageWarningNotice } from '../UsageWarningNotice'
import { bucketForMime } from '../../utils/filters'
import { useDebouncedSave } from '../../hooks/useDebouncedSave'
import { TagEditor } from '../TagEditor/TagEditor'
@@ -64,6 +71,7 @@ export interface MediaAssetEditor {
replaceAssetFile: (id: string, file: File) => Promise
restoreAsset: (id: string) => Promise
purgeAsset: (id: string) => Promise
+ lookupUsage: (assetIds: string[]) => Promise
}
interface MediaViewerWindowProps {
@@ -103,6 +111,7 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {
const { asset } = editor
const [replaceOpen, setReplaceOpen] = useState(false)
const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false)
+ const [purgeWarning, setPurgeWarning] = useState(null)
const bucket = bucketForMime(asset.mimeType)
const canWrite = canWriteMedia(currentUser)
const canReplace = canReplaceMedia(currentUser)
@@ -330,7 +339,17 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {
setPurgeConfirmOpen(true)}
+ onClick={() => {
+ // Look the usage up before the dialog appears, so the
+ // warning is part of what the operator reads rather
+ // than something that shows up after they decide.
+ void (async () => {
+ setPurgeWarning(
+ await resolveUsageWarning(editor.lookupUsage, [asset.id]),
+ )
+ setPurgeConfirmOpen(true)
+ })()
+ }}
>
Delete permanently
@@ -383,6 +402,7 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {
This removes the file and every generated size from disk. Any page
still referencing it will render a broken image.
+
,
document.body,
diff --git a/src/admin/pages/media/components/UsageWarningNotice/UsageWarningNotice.module.css b/src/admin/pages/media/components/UsageWarningNotice/UsageWarningNotice.module.css
new file mode 100644
index 000000000..d37b8a41f
--- /dev/null
+++ b/src/admin/pages/media/components/UsageWarningNotice/UsageWarningNotice.module.css
@@ -0,0 +1,47 @@
+/* The warning that appears inside a destructive confirmation when part of the
+ selection is still depended on. Warning tone, not danger: the dialog around
+ it is already danger, and repeating that here would say "this is dangerous"
+ twice while saying "and this part in particular" nowhere. */
+
+.notice {
+ display: flex;
+ gap: var(--space-l);
+ padding: var(--space-l);
+ margin-top: var(--space-l);
+ border-radius: var(--radius);
+ background: var(--warning-10);
+ color: var(--warning-text);
+ font-size: var(--text-s);
+}
+
+.icon {
+ flex: none;
+ margin-top: 1px;
+ color: var(--warning);
+}
+
+.body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ min-width: 0;
+}
+
+.heading {
+ margin: 0;
+ font-weight: 600;
+}
+
+.list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2xs);
+}
+
+.item {
+ /* A long filename or display name must not push the dialog wider. */
+ overflow-wrap: anywhere;
+}
diff --git a/src/admin/pages/media/components/UsageWarningNotice/UsageWarningNotice.tsx b/src/admin/pages/media/components/UsageWarningNotice/UsageWarningNotice.tsx
new file mode 100644
index 000000000..b2b156029
--- /dev/null
+++ b/src/admin/pages/media/components/UsageWarningNotice/UsageWarningNotice.tsx
@@ -0,0 +1,31 @@
+/**
+ * Names what a destructive media action is about to break.
+ *
+ * Rendered inside the confirmation, not instead of it: the operator is still
+ * allowed to go ahead — replacing an avatar begins by deleting the old one —
+ * so this informs and gets out of the way. `buildUsageWarning` owns the
+ * wording; this owns how it looks.
+ *
+ * `role="status"` rather than `alert`: the dialog announces itself on open and
+ * the assertive interruption belongs to that, not to a detail inside it.
+ */
+import { WarningDiamondSolidIcon } from 'pixel-art-icons/icons/warning-diamond-solid'
+import type { UsageWarning } from '../../utils/usageWarning'
+import s from './UsageWarningNotice.module.css'
+
+export function UsageWarningNotice({ warning }: { warning: UsageWarning | null }) {
+ if (!warning) return null
+ return (
+
+
+
+
{warning.heading}
+
+ {warning.lines.map((line) => (
+
{line}
+ ))}
+
+
+
+ )
+}
diff --git a/src/admin/pages/media/components/UsageWarningNotice/index.ts b/src/admin/pages/media/components/UsageWarningNotice/index.ts
new file mode 100644
index 000000000..e72bfe976
--- /dev/null
+++ b/src/admin/pages/media/components/UsageWarningNotice/index.ts
@@ -0,0 +1 @@
+export { UsageWarningNotice } from './UsageWarningNotice'
diff --git a/src/admin/pages/media/hooks/useMediaWorkspace.ts b/src/admin/pages/media/hooks/useMediaWorkspace.ts
index e82cc4b94..7ae0da975 100644
--- a/src/admin/pages/media/hooks/useMediaWorkspace.ts
+++ b/src/admin/pages/media/hooks/useMediaWorkspace.ts
@@ -424,19 +424,10 @@ export function useMediaWorkspace(): UseMediaWorkspaceResult {
return null
})
- /**
- * Never throws. A usage lookup is advisory — it decorates a confirmation
- * that must still appear if the request fails, so a network blip degrades
- * to the plain warning rather than blocking the delete.
- */
- const lookupUsage = async (assetIds: string[]): Promise => {
- try {
- return await listCmsMediaUsage(assetIds)
- } catch (err) {
- console.error('[useMediaWorkspace] usage lookup failed:', err)
- return []
- }
- }
+ // Failure is absorbed by `resolveUsageWarning`, the one door every
+ // confirmation goes through — see the note there.
+ const lookupUsage = (assetIds: string[]): Promise =>
+ listCmsMediaUsage(assetIds)
const purgeAsset = async (assetId: string): Promise => {
await assetMut('Could not delete asset permanently', async () => {
diff --git a/src/admin/pages/media/hooks/useStandaloneMediaEditor.ts b/src/admin/pages/media/hooks/useStandaloneMediaEditor.ts
index 95adfec77..ff58d1805 100644
--- a/src/admin/pages/media/hooks/useStandaloneMediaEditor.ts
+++ b/src/admin/pages/media/hooks/useStandaloneMediaEditor.ts
@@ -17,6 +17,7 @@
*/
import {
deleteCmsMediaAsset,
+ listCmsMediaUsage,
purgeCmsMediaAsset,
renameCmsMediaAsset,
replaceCmsMediaAssetFile,
@@ -24,6 +25,7 @@ import {
updateCmsMediaAsset,
type CmsMediaAsset,
type CmsMediaFolder,
+ type CmsMediaUsageRef,
type UpdateCmsMediaAssetInput,
} from '@core/persistence/cmsMedia'
import type { MediaAssetEditor } from '../components/MediaViewerWindow/MediaViewerWindow'
@@ -125,6 +127,11 @@ export function useStandaloneMediaEditor({
}
}
+ // Failure is absorbed by `resolveUsageWarning`, the one door every
+ // confirmation goes through — see the note there.
+ const lookupUsage = (assetIds: string[]): Promise =>
+ listCmsMediaUsage(assetIds)
+
if (!asset) return null
return {
asset,
@@ -135,5 +142,6 @@ export function useStandaloneMediaEditor({
replaceAssetFile,
restoreAsset,
purgeAsset,
+ lookupUsage,
}
}
diff --git a/src/admin/pages/media/utils/usageWarning.ts b/src/admin/pages/media/utils/usageWarning.ts
index bd5b1e514..e40608435 100644
--- a/src/admin/pages/media/utils/usageWarning.ts
+++ b/src/admin/pages/media/utils/usageWarning.ts
@@ -62,3 +62,38 @@ export function buildUsageWarning(
return { heading, lines: named }
}
+
+/**
+ * Ask what depends on these assets, then phrase it.
+ *
+ * NEVER REJECTS. This is the single door all three confirmations go through,
+ * so the guarantee belongs here rather than in each `lookupUsage` — the
+ * warning is advisory, and a failed request has to degrade to the plain
+ * confirmation. If this could reject, the `void (async () => …)()` that opens
+ * each dialog would drop the rejection on the floor and the dialog would
+ * never appear: a delete button that silently does nothing, which is worse
+ * than one that deletes without the extra warning.
+ *
+ * Deliberately NOT a hook holding state: every caller awaits this immediately
+ * before opening its confirmation, and a `setState` would not be visible in
+ * the closure that opens the dialog — the warning would always be one delete
+ * behind. Callers that need it across renders (a dialog kept open) store the
+ * returned value themselves.
+ *
+ * `selectionSize` is what the operator is being asked about, which is not
+ * always `assetIds.length`: the bulk window purges only the trashed members of
+ * a mixed selection, so "1 of 3" has to count the three it will delete.
+ */
+export async function resolveUsageWarning(
+ lookupUsage: (assetIds: string[]) => Promise,
+ assetIds: string[],
+ selectionSize: number = assetIds.length,
+): Promise {
+ if (assetIds.length === 0) return null
+ try {
+ return buildUsageWarning(selectionSize, await lookupUsage(assetIds))
+ } catch (err) {
+ console.error('[usageWarning] usage lookup failed:', err)
+ return null
+ }
+}
diff --git a/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteContext.tsx b/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteContext.tsx
index 82ba0edbe..7db4eb7e3 100644
--- a/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteContext.tsx
+++ b/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteContext.tsx
@@ -58,6 +58,7 @@ export function ConfirmDeleteProvider({ children }: { children: ReactNode }) {
title={pending.request.title}
description={pending.request.description}
confirmLabel={pending.request.confirmLabel}
+ details={pending.request.details}
onCancel={handleCancel}
onConfirm={handleConfirm}
/>
diff --git a/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx b/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx
index cd3016b23..9519ab3f1 100644
--- a/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx
+++ b/src/admin/shared/dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx
@@ -11,7 +11,7 @@
* only owns the two-button confirmation contract.
*/
-import { useEffect, useRef } from 'react'
+import { useEffect, useRef, type ReactNode } from 'react'
import { Button } from '@ui/components/Button'
import { Dialog } from '@ui/components/Dialog'
@@ -24,6 +24,8 @@ interface ConfirmDeleteDialogProps {
confirmLabel?: string
/** Cancel button label — defaults to "Cancel". */
cancelLabel?: string
+ /** Caller-owned content rendered below the description. */
+ details?: ReactNode
onCancel: () => void
onConfirm: () => void
}
@@ -33,6 +35,7 @@ export function ConfirmDeleteDialog({
description,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
+ details,
onCancel,
onConfirm,
}: ConfirmDeleteDialogProps) {
@@ -79,6 +82,7 @@ export function ConfirmDeleteDialog({
}
>
{description &&
{description}
}
+ {details}
)
}
diff --git a/src/admin/shared/dialogs/ConfirmDeleteDialog/confirmDeleteHook.ts b/src/admin/shared/dialogs/ConfirmDeleteDialog/confirmDeleteHook.ts
index 711319db3..eecc8eb90 100644
--- a/src/admin/shared/dialogs/ConfirmDeleteDialog/confirmDeleteHook.ts
+++ b/src/admin/shared/dialogs/ConfirmDeleteDialog/confirmDeleteHook.ts
@@ -6,7 +6,7 @@
* `frameworkChangeConfirmHook.ts`.
*/
-import { createContext, use } from 'react'
+import { createContext, use, type ReactNode } from 'react'
export interface ConfirmDeleteRequest {
/** Short title shown in the dialog header. e.g. "Delete header layer?" */
@@ -15,6 +15,12 @@ export interface ConfirmDeleteRequest {
description?: string
/** Confirm button label — defaults to "Delete". */
confirmLabel?: string
+ /**
+ * Extra content below the description — a caller-owned detail the generic
+ * dialog knows nothing about. Media passes the list of things that still
+ * depend on the files being deleted; most callers pass nothing.
+ */
+ details?: ReactNode
/**
* Force this request through the dialog even when the user's
* `confirmBeforeDelete` preference is off. Use for destructive actions with
From 63ceceb05d4f0880fd27bc1ba26b04d67441e7f5 Mon Sep 17 00:00:00 2001
From: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com>
Date: Sun, 6 Sep 2026 15:18:13 +0200
Subject: [PATCH 6/7] fix(media): drop a hardcoded pixel nudge from the usage
notice
The spacing gate caught a `margin-top: 1px` optical alignment on the
warning icon. A 1px nudge is not worth an exception to the token scale.
Co-Authored-By: Claude Opus 5
---
.../components/UsageWarningNotice/UsageWarningNotice.module.css | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/admin/pages/media/components/UsageWarningNotice/UsageWarningNotice.module.css b/src/admin/pages/media/components/UsageWarningNotice/UsageWarningNotice.module.css
index d37b8a41f..fcb13f650 100644
--- a/src/admin/pages/media/components/UsageWarningNotice/UsageWarningNotice.module.css
+++ b/src/admin/pages/media/components/UsageWarningNotice/UsageWarningNotice.module.css
@@ -16,7 +16,6 @@
.icon {
flex: none;
- margin-top: 1px;
color: var(--warning);
}
From 1ac5a89fcf4bda936078f5f7e2084aae17d176d3 Mon Sep 17 00:00:00 2001
From: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com>
Date: Sun, 6 Sep 2026 16:13:18 +0200
Subject: [PATCH 7/7] feat(media): work out which pages use a file, instead of
recording it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Avatars could be RECORDED because a profile picture has one writer and an
explicit set/unset. Page content has neither: it is written continuously by
the collab relay, and taking an image off a page emits no event at all. A
table fed by that would slowly fill with references to nodes that no longer
exist, and the delete warning would start naming pages that are fine.
A warning that is sometimes wrong is worse than none — it gets ignored. So
content usage is computed when it is asked for, from the draft site
document, which cannot drift because there is nothing to keep in sync. The
test that matters pins exactly that: remove the image, and the reference is
gone on the next question.
The walk already existed. `mediaPrefetch` has resolved image/media props on
every publish for a long time, including background images and the
definition tree of every Visual Component a page references; it splits into
a page-scoped half this can reuse, with publish behaviour unchanged. The
module schema is already the registry of which props hold a reference, so
there is no second list to keep honest.
The cost lands on permanent delete and nowhere else — never a page load,
never a trash. Reading the DRAFT document rather than published artefacts is
deliberate: an image on an unpublished page is still in use, and a check
that only knew about live pages would let a delete quietly break the next
publish.
Two things worth stating:
`contentUsage.ts` imports `@modules/base` itself. Without the registry
populated the walk matches nothing and reports NO usage — a warning that is
silently always empty, which is the one failure mode worse than not having
it.
The warning now counts per asset but names per place. One file used on
three pages is one file lost — "3 of 11" would overstate the damage — but it
is three pages to go and fix, and naming one of them sends the operator to
repair a third of the breakage.
Co-Authored-By: Claude Opus 5
---
server/handlers/cms/media.ts | 16 +-
server/media/contentUsage.ts | 126 ++++++++++++
server/publish/mediaPrefetch.ts | 23 ++-
src/__tests__/media/usageWarning.test.ts | 39 +++-
.../server/mediaContentUsage.test.ts | 185 ++++++++++++++++++
src/admin/pages/media/utils/usageWarning.ts | 28 ++-
6 files changed, 403 insertions(+), 14 deletions(-)
create mode 100644 server/media/contentUsage.ts
create mode 100644 src/__tests__/server/mediaContentUsage.test.ts
diff --git a/server/handlers/cms/media.ts b/server/handlers/cms/media.ts
index 3f5de3852..51d3bc7ef 100644
--- a/server/handlers/cms/media.ts
+++ b/server/handlers/cms/media.ts
@@ -15,7 +15,9 @@
* assets) and removes the file
* (`media.delete`)
* POST /admin/api/cms/media/usage — which of these assets are still
- * depended on, and by what
+ * depended on, and by what:
+ * recorded settings refs plus
+ * page content, computed live
* POST /admin/api/cms/media/:id/restore — restore a soft-deleted asset
* (`media.write`)
* POST /admin/api/cms/media/:id/replace — overwrite the bytes for an asset
@@ -40,6 +42,7 @@
*/
import type { DbClient } from '../../db/client'
import { requireCapability } from '../../auth/authz'
+import { collectContentUsageRefs } from '../../media/contentUsage'
import {
assignAssetToFolders,
deleteMediaAsset,
@@ -313,7 +316,16 @@ async function handleMediaUsage(req: Request, db: DbClient): Promise {
const body = await readValidatedBody(req, MediaUsageQuerySchema)
if (!body) return badRequest('Invalid request body')
- return jsonResponse({ usage: await listMediaUsageRefs(db, body.assetIds) })
+ // Two sources, one answer. Settings (an avatar, later a favicon) are
+ // recorded in `media_usage_refs` because they have one writer and an
+ // explicit set/unset. Page content is COMPUTED, because it has neither —
+ // see `server/media/contentUsage.ts` for why a table would go wrong there.
+ // Both run in parallel; the caller cannot tell which side a ref came from.
+ const [stored, content] = await Promise.all([
+ listMediaUsageRefs(db, body.assetIds),
+ collectContentUsageRefs(db, body.assetIds),
+ ])
+ return jsonResponse({ usage: [...stored, ...content] })
}
const MediaUsageQuerySchema = Type.Object({
diff --git a/server/media/contentUsage.ts b/server/media/contentUsage.ts
new file mode 100644
index 000000000..b97b585e3
--- /dev/null
+++ b/server/media/contentUsage.ts
@@ -0,0 +1,126 @@
+/**
+ * Which pages use these files — worked out from the site itself, not from a
+ * stored index.
+ *
+ * The counterpart to `media_usage_refs`, and the split between them is the
+ * point: a stored reference suits a SETTING, which has one writer and an
+ * explicit set/unset — an avatar, a favicon, a logo. Page content has
+ * neither. It is written continuously by the collab relay, and removing an
+ * image produces no event at all, so a table would fill with references to
+ * nodes that no longer exist and the warning would start being wrong.
+ *
+ * A wrong warning is worse than none: an operator who is misled once stops
+ * reading it. So this computes the answer at the moment it is asked, from
+ * `getDraftSiteDocument` — which cannot drift, because there is nothing to
+ * keep in sync.
+ *
+ * The cost lands where it belongs. Walking every page tree is O(site), and it
+ * happens only when someone asks to permanently delete something — never on a
+ * page load, never on a trash. For the site sizes this product is built for,
+ * that is a few milliseconds on an action that is about to be irreversible.
+ * If a site ever grows past that, the fix is to cache this — with the walk
+ * still the source of truth, so the cache can be checked against it.
+ *
+ * Deliberately reads the DRAFT document, not the published artefacts: an
+ * image placed on an unpublished page is still in use, and a warning that
+ * only knew about live pages would let a delete quietly break the next
+ * publish.
+ */
+
+// Registry population. The walk asks the registry which props are
+// image/media-typed, so without the base modules registered it matches
+// nothing and reports NO usage — a warning that is silently always empty,
+// which is the one failure mode worse than not having it. Same import
+// `pageDiff.ts` and the collab relay make, and for the same reason.
+import '@modules/base'
+import { registry } from '@core/module-engine'
+import { collectSiteStyleBackgroundImagePaths } from '@core/publisher'
+import { placeholder, type DbClient } from '../db/client'
+import { getDraftSiteDocument } from '../repositories/publish'
+import { collectPageMediaPaths } from '../publish/mediaPrefetch'
+import type { MediaUsageRef } from '../repositories/media'
+
+/**
+ * `ref_kind` values this module produces. They share the namespace with the
+ * stored kinds (`user.avatar`), so a caller merges the two lists without
+ * caring which side each one came from.
+ */
+export const PAGE_CONTENT_REF_KIND = 'page.content'
+export const SITE_STYLES_REF_KIND = 'site.styles'
+
+/**
+ * Map the requested asset ids to the `public_path` each one is stored under.
+ *
+ * Content props hold the path, not the id — and `replaceMediaAssetBinary`
+ * keeps the path stable across a file swap precisely so page references
+ * survive it. The path is therefore the join key, and this is the one query
+ * that translates.
+ */
+async function pathsForAssetIds(
+ db: DbClient,
+ assetIds: string[],
+): Promise