Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/media-usage-activation-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Adds guided Media Usage setup and historical indexing progress for administrators.
11 changes: 7 additions & 4 deletions docs/src/content/docs/deployment/cloudflare.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,13 @@ To use different schedules, set the corresponding `generalCron` or `mediaUsageCr

### Enable automatic media usage indexing

Keep `mediaUsageCron` running while you enable automatic media usage indexing. Pause all application
and direct database writes, follow [Enable automatic media usage
indexing](/reference/rest-api/#enable-automatic-media-usage-indexing), then resume writes when the
endpoint returns `active`. Existing content is indexed in the background.
Keep `mediaUsageCron` running, then pause all application and direct database writes. Open
**Settings → Media Usage**, select **Enable Media Usage**, confirm the safety requirements, and use
**Continue setup** until setup finishes. Resume writers only after activation is active. The page
then shows how many content types have finished indexing existing content.

The [REST procedure](/reference/rest-api/#enable-automatic-media-usage-indexing) remains available
for API operators.

## Deploy

Expand Down
11 changes: 7 additions & 4 deletions docs/src/content/docs/deployment/nodejs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,13 @@ scheduled tasks pause.

### Enable automatic media usage indexing

Keep at least one Node process running while you enable automatic media usage indexing. Pause all
application and direct database writes, follow [Enable automatic media usage
indexing](/reference/rest-api/#enable-automatic-media-usage-indexing), then resume writes when the
endpoint returns `active`. Existing content is indexed in the background.
Keep at least one Node process running, then pause all application and direct database writes. Open
**Settings → Media Usage**, select **Enable Media Usage**, confirm the safety requirements, and use
**Continue setup** until setup finishes. Resume writers only after activation is active. The page
then shows how many content types have finished indexing existing content.

The [REST procedure](/reference/rest-api/#enable-automatic-media-usage-indexing) remains available
for API operators.

## Production Storage

Expand Down
14 changes: 14 additions & 0 deletions docs/src/content/docs/guides/media-library.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ Open the media library from the admin sidebar by clicking **Media**. The library

<img src={mediaLibraryImg.src} alt="EmDash media library showing image grid with upload button" />

## Used in

Open a local media item's details to see known references from EmDash-managed content fields.
Already-found references remain visible while historical content is still indexing.

Administrators see a Media Library notice until automatic indexing is active. Open **Settings →
Media Usage**, enable the feature, and continue its bounded setup steps. Once active, the Settings
page shows how many content types have finished indexing existing content.

<Aside type="caution">
Used in results are advisory. They do not prove that a media item is universally unused or safe to
delete.
</Aside>

## Uploading Files

### From the Media Library
Expand Down
13 changes: 13 additions & 0 deletions docs/src/content/docs/reference/rest-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,9 @@ paused while EmDash prepares each collection so that no changes are missed.

Both endpoints require `schema:manage`. Bearer tokens also require the `admin` scope.

Administrators can run the same procedure from **Settings → Media Usage**. The page sends one POST
per deliberate setup action; it cannot stop external writers or verify scheduled maintenance.

#### Check the current state

```http
Expand Down Expand Up @@ -429,6 +432,16 @@ again. Do not edit EmDash's internal database tables.
When the state is `active`, EmDash tracks changes to media references in content. Existing content
may still be indexing in the background.

#### Check historical indexing progress

```http
GET /_emdash/api/admin/media-usage/progress
```

After activation is active, this returns `indexing`, `ready`, or `needs_attention` together with the
number of ready and total current content types. It does not inspect content rows or return work-item
details. The endpoint requires `schema:manage`; bearer tokens also require the `admin` scope.

### List Media Usage Work

```http
Expand Down
4 changes: 3 additions & 1 deletion packages/admin/src/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface ConfirmDialogProps {
/** Button variant — defaults to "destructive" */
variant?: "destructive" | "primary";
isPending: boolean;
disabled?: boolean;
/** Error from a mutation — pass mutation.error directly */
error: unknown;
onConfirm: () => void;
Expand All @@ -40,6 +41,7 @@ export function ConfirmDialog({
pendingLabel,
variant = "destructive",
isPending,
disabled = false,
error,
onConfirm,
children,
Expand All @@ -56,7 +58,7 @@ export function ConfirmDialog({
<Button variant="secondary" onClick={onClose}>
{t`Cancel`}
</Button>
<Button variant={variant} disabled={isPending} onClick={onConfirm}>
<Button variant={variant} disabled={disabled || isPending} onClick={onConfirm}>
{isPending ? pendingLabel : confirmLabel}
</Button>
</div>
Expand Down
33 changes: 32 additions & 1 deletion packages/admin/src/components/MediaLibrary.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Button, Input, Loader, Select, Tabs } from "@cloudflare/kumo";
import { Banner, Button, Input, Loader, Select, Tabs } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import {
Expand All @@ -22,6 +22,11 @@ import {
fetchProviderMedia,
uploadToProvider,
} from "../lib/api";
import { useCurrentUser } from "../lib/api/current-user.js";
import {
MEDIA_USAGE_ACTIVATION_QUERY_KEY,
fetchMediaUsageActivationStatus,
} from "../lib/api/media-usage-activation.js";
import { useDebouncedValue } from "../lib/hooks.js";
import {
providerItemToMediaItem,
Expand All @@ -33,6 +38,7 @@ import {
} from "../lib/media-utils";
import { cn } from "../lib/utils";
import { MediaDetailPanel } from "./MediaDetailPanel";
import { RouterLinkButton } from "./RouterLinkButton.js";

/** Maps a coarse type-filter choice to the media list's `mimeType` filter. */
function mimeForTypeFilter(value: string): string | string[] | undefined {
Expand Down Expand Up @@ -80,6 +86,17 @@ export function MediaLibrary({
onLocalMimeFilterChange,
}: MediaLibraryProps) {
const { t } = useLingui();
const isAdmin = (useCurrentUser().data?.role ?? 0) >= 50;
const activationQuery = useQuery({
queryKey: MEDIA_USAGE_ACTIVATION_QUERY_KEY,
queryFn: fetchMediaUsageActivationStatus,
enabled: isAdmin,
retry: false,
staleTime: 60_000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const setupStatus = isAdmin && !activationQuery.isError ? activationQuery.data : undefined;
const [viewMode, setViewMode] = React.useState<"grid" | "list">("grid");
const [detailItem, setDetailItem] = React.useState<MediaItem | null>(null);
const [isDetailOpen, setIsDetailOpen] = React.useState(false);
Expand Down Expand Up @@ -368,6 +385,20 @@ export function MediaLibrary({
)}
</div>
</div>
{activeProvider === "local" && setupStatus && setupStatus.state !== "active" ? (
<Banner
variant="alert"
title={
setupStatus.state === "activating" ? t`Finish Media Usage setup` : t`Set up Media Usage`
}
description={t`Index existing content and keep Used in results up to date.`}
action={
<RouterLinkButton to="/settings/media-usage" size="sm" variant="secondary">
{setupStatus.state === "activating" ? t`Continue setup` : t`Enable Media Usage`}
</RouterLinkButton>
}
/>
) : null}

{/* Provider tabs (only when an external provider is configured) */}
{providerTabs.length > 1 && (
Expand Down
15 changes: 15 additions & 0 deletions packages/admin/src/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import {
Envelope,
DownloadSimple,
CaretDown,
ChartLineUp,
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import * as React from "react";

import { fetchManifest } from "../lib/api";
import { useCurrentUser } from "../lib/api/current-user.js";
import { SUPPORTED_LOCALES } from "../locales/index.js";
import { useLocale } from "../locales/useLocale.js";
import { SettingsNavRow, SettingsSection } from "./settings/SettingsLayout.js";
Expand All @@ -24,6 +26,7 @@ import { SettingsNavRow, SettingsSection } from "./settings/SettingsLayout.js";
* Settings hub page — links to all settings sub-pages.
*/
export function Settings() {
const { data: currentUser } = useCurrentUser();
const { data: manifest } = useQuery({
queryKey: ["manifest"],
queryFn: fetchManifest,
Expand All @@ -32,6 +35,7 @@ export function Settings() {
const { t } = useLingui();
const { locale, setLocale } = useLocale();
const showSecuritySettings = manifest?.authMode === "passkey";
const showMediaUsageSettings = (currentUser?.role ?? 0) >= 50;
const selectedLocale = SUPPORTED_LOCALES.find((option) => option.code === locale) ?? null;

return (
Expand Down Expand Up @@ -62,6 +66,17 @@ export function Settings() {
/>
</SettingsSection>

{showMediaUsageSettings ? (
<SettingsSection title={t`Media`}>
<SettingsNavRow
to="/settings/media-usage"
icon={<ChartLineUp className="h-5 w-5" />}
title={t`Media Usage`}
description={t`Track where media is used across your content`}
/>
</SettingsSection>
) : null}

{showSecuritySettings && (
<SettingsSection title={t`Security Settings`}>
<SettingsNavRow
Expand Down
Loading
Loading