diff --git a/.changeset/bright-otters-edit.md b/.changeset/bright-otters-edit.md
new file mode 100644
index 0000000000..6ab8ab29d8
--- /dev/null
+++ b/.changeset/bright-otters-edit.md
@@ -0,0 +1,8 @@
+---
+"@emdash-cms/admin": minor
+---
+
+Adds in-context Media Library asset editing to admin image pickers, image fields, and rich text
+images and galleries. Editors can update asset metadata and focal points, create and select cropped
+copies, or replace original image data while staying in the content editor. Gallery images also
+support keyboard reordering.
diff --git a/docs/src/content/docs/guides/media-library.mdx b/docs/src/content/docs/guides/media-library.mdx
index 49da78601e..c0cd4f170e 100644
--- a/docs/src/content/docs/guides/media-library.mdx
+++ b/docs/src/content/docs/guides/media-library.mdx
@@ -241,6 +241,21 @@ references remain unchanged.
For fields configured as image or file types, select the field action to open the same media
picker. The field's MIME type rules limit the sources and files you can choose.
+### Edit a selected image asset
+
+Local image fields, rich text images, and gallery images provide three actions:
+
+- **Replace** changes the image used in the current field, block, or gallery position.
+- **Edit asset** opens Media Details for the selected Media Library item. You can update its alt
+ text, caption, focal point, or crop while staying in the content editor.
+- **Remove** clears the current content reference. The Media Library item remains available.
+
+**Create cropped copy** selects the new copy for the current usage. Rich text and gallery images
+keep their per-use alt text, caption, layout, and position. **Replace original** keeps the same media
+reference and changes the image anywhere that asset is used.
+
+External-provider images and file fields provide **Replace** and **Remove**, but not **Edit asset**.
+
## Replacing an image
Use **Replace image** to update the file behind an existing local media item. Authors can replace
@@ -270,14 +285,16 @@ the replacement. Replacing the file clears its focal point.
A focal point keeps the important part of a local image visible when a card, gallery, or other
layout crops it to fill a fixed shape.
-1. Open **Media**, then select an image from the local library.
+1. Open **Media** and select an image from the local library, or select **Edit asset** for a local
+ image in the content editor.
2. Select **Edit image**, then **Focal point**.
3. Click or drag the marker onto the important part of the image. You can also use the Arrow keys.
4. Check the square, landscape, and portrait previews, then select **Save**.
Select **Reset** to remove a custom focal point. The saved point is copied when you select
-the image for a content field or gallery. Content that already uses the image keeps its stored point
-until you select the image again.
+the image for a content field or gallery. Other content already using the image keeps its stored point
+until you select the image again. When you edit an asset from a content field or gallery, that current
+usage refreshes with the saved focal point.
## Cropping an image
diff --git a/e2e/tests/media-library.spec.ts b/e2e/tests/media-library.spec.ts
index 2e587d0b30..89c1d5c3c8 100644
--- a/e2e/tests/media-library.spec.ts
+++ b/e2e/tests/media-library.spec.ts
@@ -399,6 +399,156 @@ test.describe("Media Library", () => {
).toBe(true);
});
+ test("edits a selected content image and uses a cropped copy without leaving the editor", async ({
+ admin,
+ page,
+ serverInfo,
+ }) => {
+ test.setTimeout(90_000);
+ const marker = Date.now();
+ const filename = `editor-asset-${marker}.png`;
+ const duplicateFilename = `editor-asset-${marker}-square.png`;
+ await admin.goToMedia();
+ await admin.waitForLoading();
+ await uploadCropTestImage(page, filename);
+ const original = await findMediaByFilename(serverInfo, filename);
+
+ await admin.goto("/content/posts/new");
+ await admin.waitForShell();
+ await admin.waitForLoading();
+ const editorUrl = page.url();
+ await page.getByRole("button", { name: "Select image" }).click();
+ const picker = page.getByRole("dialog", { name: "Select Featured Image" });
+ const workspaceElement = await picker.elementHandle();
+ expect(workspaceElement).not.toBeNull();
+ const workspaceWidth = await picker.evaluate((dialog) => dialog.clientWidth);
+ await picker.getByRole("searchbox", { name: "Search media" }).fill(filename);
+ await picker.getByRole("button", { name: filename, exact: true }).click();
+ await picker.getByRole("button", { name: "Edit asset" }).click();
+ const pickerDetails = page.getByRole("dialog", { name: "Media details" });
+ await expect(pickerDetails).toBeVisible();
+ expect(await pickerDetails.evaluate((dialog) => dialog.clientWidth)).toBe(workspaceWidth);
+ expect(
+ await pickerDetails.evaluate(
+ (dialog, originalDialog) => dialog === originalDialog,
+ workspaceElement,
+ ),
+ ).toBe(true);
+ await expect(page.getByRole("dialog")).toHaveCount(1);
+ await expect(pickerDetails.getByRole("searchbox", { name: "Search media" })).toHaveCount(0);
+ await pickerDetails.getByRole("button", { name: "Back" }).click();
+ await expect(picker).toBeVisible();
+ await expect(picker.getByRole("button", { name: "Edit asset" })).toBeFocused();
+ expect(
+ await picker.evaluate(
+ (dialog, originalDialog) => dialog === originalDialog,
+ workspaceElement,
+ ),
+ ).toBe(true);
+ await expect(picker.getByRole("button", { name: filename, exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await picker.getByRole("button", { name: "Select", exact: true }).click();
+ const featuredImageField = page.locator("#field-featured_image");
+ await expect(featuredImageField.getByText(filename, { exact: true })).toBeVisible();
+
+ await featuredImageField.getByRole("button", { name: "Edit asset" }).click();
+ const details = page.getByRole("dialog", { name: "Media details" });
+ await expect(details).toBeVisible();
+ await expect(picker).not.toBeVisible();
+ await expect(details.getByRole("tab", { name: "Used in" })).toHaveCount(0);
+ await expect(details.getByRole("button", { name: "Delete" })).toHaveCount(0);
+ expect(page.url()).toBe(editorUrl);
+
+ await details.getByRole("tab", { name: "Edit image" }).click();
+ const aspectRatio = details.getByRole("combobox", { name: "Aspect ratio" });
+ await aspectRatio.click();
+ await page.getByRole("option", { name: "Square (1:1)" }).click();
+ const duplicateResponse = page.waitForResponse(
+ (response) =>
+ response.request().method() === "POST" &&
+ new URL(response.url()).pathname.endsWith("/confirm") &&
+ response.status() === 200,
+ );
+ await details.getByRole("button", { name: "Create cropped copy" }).click();
+ await duplicateResponse;
+
+ await expect(details).not.toBeVisible();
+ await expect(featuredImageField.getByText(duplicateFilename, { exact: true })).toBeVisible();
+ expect(page.url()).toBe(editorUrl);
+ const duplicate = await findMediaByFilename(serverInfo, duplicateFilename);
+ expect(duplicate.id).not.toBe(original.id);
+ });
+
+ test("keeps featured image actions compact and reachable on mobile", async ({ admin, page }) => {
+ test.setTimeout(60_000);
+ await admin.goto("/content/posts/new");
+ await admin.waitForShell();
+ await admin.waitForLoading();
+ await page.setViewportSize({ width: 320, height: 800 });
+ await page.getByRole("button", { name: "Select image" }).click();
+ const picker = page.getByRole("dialog", { name: "Select Featured Image" });
+ await picker.getByRole("button", { name: "test-image.png", exact: true }).click();
+ await picker.getByRole("button", { name: "Select", exact: true }).click();
+
+ const featuredImageField = page.locator("#field-featured_image");
+ await expect(featuredImageField.getByText("test-image.png", { exact: true })).toBeVisible();
+ const featuredPreview = featuredImageField.locator(".emdash-featured-image-preview");
+ const featuredCard = featuredPreview.locator("..");
+ expect(
+ await featuredPreview.evaluate((element) => element.getBoundingClientRect().height),
+ ).toBeLessThan(80);
+ expect(
+ await featuredCard.evaluate((element) => element.getBoundingClientRect().height),
+ ).toBeLessThan(120);
+ const imageActions = featuredImageField.getByRole("button", { name: "Image actions" });
+ await expect(imageActions).toBeVisible();
+ await expect(featuredImageField.getByRole("button", { name: "Replace" })).toHaveCount(0);
+ await expect(featuredImageField.getByRole("button", { name: "Edit asset" })).toHaveCount(0);
+ await expect(featuredImageField.getByRole("button", { name: "Remove image" })).toHaveCount(0);
+
+ await imageActions.click();
+ await expect(imageActions).toHaveAttribute("aria-expanded", "true");
+ await expect(page.getByRole("menuitem", { name: "Replace" })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: "Edit asset" })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: "Remove" })).toBeVisible();
+ await page.setViewportSize({ width: 640, height: 800 });
+ await expect(page.getByRole("menu", { name: "Image actions" })).not.toBeVisible();
+ await expect(featuredImageField.getByRole("button", { name: "Replace" })).toBeVisible();
+ await page.setViewportSize({ width: 320, height: 800 });
+ await expect(imageActions).toHaveAttribute("aria-expanded", "false");
+ await imageActions.click();
+ await page.getByRole("menuitem", { name: "Replace" }).click();
+ const replacePicker = page.getByRole("dialog", { name: "Replace Featured Image" });
+ await expect(replacePicker).toBeVisible();
+ await replacePicker.getByRole("button", { name: "Close" }).click();
+
+ await imageActions.click();
+ await page.getByRole("menuitem", { name: "Edit asset" }).click();
+ const details = page.getByRole("dialog", { name: "Media details" });
+ await expect(details).toBeVisible();
+ await details.getByRole("button", { name: "Close" }).click();
+ await expect(details).not.toBeVisible();
+ await expect(imageActions).toBeFocused();
+ await featuredPreview.locator("img").dispatchEvent("error");
+ await expect(featuredPreview.getByText("Image not found")).toHaveCount(1);
+ expect(
+ await featuredCard.evaluate((element) => element.getBoundingClientRect().height),
+ ).toBeLessThan(120);
+ await expect(imageActions).toBeVisible();
+
+ await imageActions.click();
+ await page.getByRole("menuitem", { name: "Remove" }).click();
+ await expect(featuredImageField.getByRole("button", { name: "Select image" })).toBeVisible();
+ expect(
+ await featuredImageField.evaluate((element) => element.scrollWidth <= element.clientWidth),
+ ).toBe(true);
+ expect(
+ await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
+ ).toBe(true);
+ });
+
test.describe("List View", () => {
test("shows file details in list view", async ({ admin, page }) => {
// Upload a file first so there's something to show
diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx
index 36e4eb2271..274af8c6e9 100644
--- a/packages/admin/src/components/ContentEditor.tsx
+++ b/packages/admin/src/components/ContentEditor.tsx
@@ -1858,19 +1858,19 @@ function FileFieldRenderer({
)}
-
+
}
onClick={handleRemove}
aria-label={t`Remove ${label}`}
>
-
+ {t`Remove`}
@@ -1896,7 +1896,8 @@ function FileFieldRenderer({
fieldId={fieldId}
hideUrlInput
mediaKind="file"
- title={t`Select ${label}`}
+ title={normalized ? t`Replace ${label}` : t`Select ${label}`}
+ confirmLabel={normalized ? t`Replace` : undefined}
/>
{required && !normalized && (
{t`This field is required`}
diff --git a/packages/admin/src/components/ImageFieldRenderer.tsx b/packages/admin/src/components/ImageFieldRenderer.tsx
index 912d9e02a8..2292891b39 100644
--- a/packages/admin/src/components/ImageFieldRenderer.tsx
+++ b/packages/admin/src/components/ImageFieldRenderer.tsx
@@ -8,9 +8,17 @@
* sub-fields) can reuse the same picker without a circular import.
*/
-import { Button, Label, LayerCard, Text } from "@cloudflare/kumo";
+import { Button, DropdownMenu, Label, LayerCard, Text, Tooltip } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
-import { Image as ImageIcon, ImageBroken, ImageSquare, Moon, X } from "@phosphor-icons/react";
+import {
+ Image as ImageIcon,
+ ImageBroken,
+ ImageSquare,
+ DotsThree,
+ Moon,
+ PencilSimple,
+ X,
+} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
@@ -22,6 +30,7 @@ import {
metaString,
} from "../lib/media-utils.js";
import { FieldHelpLabel } from "./FieldHelpLabel.js";
+import { useMediaAssetEditor } from "./media/useMediaAssetEditor.js";
import { MediaPickerModal } from "./MediaPickerModal";
/**
@@ -68,6 +77,28 @@ function mediaDisplayUrl(value: ImageFieldValue | string | undefined): string |
return undefined;
}
+function mediaItemToImageFieldValue(item: MediaItem): ImageFieldValue {
+ const provider = canonicalMediaProviderId(item.provider);
+ const isLocalProvider = provider === "local";
+ const isDirectUrl = provider === "external";
+ return {
+ id: item.id,
+ provider,
+ src: isDirectUrl ? item.url : undefined,
+ previewUrl: !isLocalProvider && !isDirectUrl ? item.url : undefined,
+ alt: item.alt || "",
+ width: item.width,
+ height: item.height,
+ focalX: item.focalX ?? undefined,
+ focalY: item.focalY ?? undefined,
+ filename: item.filename,
+ mimeType: item.mimeType,
+ blurhash: item.blurhash ?? metaString(item.meta, "blurhash"),
+ dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"),
+ meta: isLocalProvider ? { ...item.meta, storageKey: item.storageKey } : item.meta,
+ };
+}
+
export interface ImageFieldRendererProps {
id?: string;
label: string;
@@ -97,8 +128,13 @@ export function ImageFieldRenderer({
const { t } = useLingui();
const [pickerOpen, setPickerOpen] = React.useState(false);
const [pickerTarget, setPickerTarget] = React.useState<"image" | "darkVariant">("image");
+ const [mobileActionsOpen, setMobileActionsOpen] = React.useState(false);
const [imageBroken, setImageBroken] = React.useState(false);
const [darkImageBroken, setDarkImageBroken] = React.useState(false);
+ const mobileImageActionsRef = React.useRef(null);
+ const [editedContentHashes, setEditedContentHashes] = React.useState<
+ Record
+ >({});
// A legacy string URL needs object form to carry a dark variant. The runtime
// resolves the URL in `src` on save, so the provider linkage survives.
const objectValue: ImageFieldValue | undefined =
@@ -108,6 +144,19 @@ export function ImageFieldRenderer({
? { id: "", src: value }
: undefined;
const darkValue = objectValue?.darkVariant;
+ const handleAssetItemChanged = React.useCallback(
+ (item: MediaItem) => {
+ const selected = mediaItemToImageFieldValue(item);
+ setEditedContentHashes((current) => ({ ...current, [item.id]: item.contentHash }));
+ if (pickerTarget === "darkVariant") {
+ if (objectValue) onChange({ ...objectValue, darkVariant: selected });
+ return;
+ }
+ onChange(darkValue ? { ...selected, darkVariant: darkValue } : selected);
+ },
+ [darkValue, objectValue, onChange, pickerTarget],
+ );
+ const assetEditor = useMediaAssetEditor(handleAssetItemChanged);
const currentMediaId =
variant === "featured" &&
objectValue?.id &&
@@ -131,12 +180,20 @@ export function ImageFieldRenderer({
enabled: currentDarkMediaId !== null,
});
const storedDisplayUrl = mediaDisplayUrl(value);
+ const primaryContentHash =
+ objectValue?.id && Object.hasOwn(editedContentHashes, objectValue.id)
+ ? editedContentHashes[objectValue.id]
+ : currentMedia?.contentHash;
const displayUrl = storedDisplayUrl
- ? getMediaPreviewUrl(storedDisplayUrl, currentMedia?.contentHash)
+ ? getMediaPreviewUrl(storedDisplayUrl, primaryContentHash)
: undefined;
const storedDarkDisplayUrl = mediaDisplayUrl(darkValue);
+ const darkContentHash =
+ darkValue?.id && Object.hasOwn(editedContentHashes, darkValue.id)
+ ? editedContentHashes[darkValue.id]
+ : currentDarkMedia?.contentHash;
const darkDisplayUrl = storedDarkDisplayUrl
- ? getMediaPreviewUrl(storedDarkDisplayUrl, currentDarkMedia?.contentHash)
+ ? getMediaPreviewUrl(storedDarkDisplayUrl, darkContentHash)
: undefined;
React.useEffect(() => {
@@ -147,36 +204,23 @@ export function ImageFieldRenderer({
setDarkImageBroken(false);
}, [darkDisplayUrl]);
+ React.useEffect(() => {
+ if (variant !== "featured") return;
+ const desktop = window.matchMedia("(min-width: 640px)");
+ const closeMobileActions = (event: MediaQueryListEvent) => {
+ if (event.matches) setMobileActionsOpen(false);
+ };
+ desktop.addEventListener("change", closeMobileActions);
+ return () => desktop.removeEventListener("change", closeMobileActions);
+ }, [variant]);
+
const openPicker = (target: "image" | "darkVariant") => {
setPickerTarget(target);
setPickerOpen(true);
};
const handleSelect = (item: MediaItem) => {
- const provider = canonicalMediaProviderId(item.provider);
- const isLocalProvider = provider === "local";
- const isDirectUrl = provider === "external";
-
- const selected: ImageFieldValue = {
- id: item.id,
- provider,
- // Local media derives its URL from storageKey. Direct URLs persist src,
- // while external providers cache a preview URL for the admin.
- src: isDirectUrl ? item.url : undefined,
- previewUrl: !isLocalProvider && !isDirectUrl ? item.url : undefined,
- alt: item.alt || "",
- width: item.width,
- height: item.height,
- focalX: item.focalX ?? undefined,
- focalY: item.focalY ?? undefined,
- filename: item.filename,
- mimeType: item.mimeType,
- // Cache LQIP alongside dimensions so embeds render a placeholder without a
- // runtime lookup. Fall back to `meta` for providers that stash it there.
- blurhash: item.blurhash ?? metaString(item.meta, "blurhash"),
- dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"),
- meta: isLocalProvider ? { ...item.meta, storageKey: item.storageKey } : item.meta,
- };
+ const selected = mediaItemToImageFieldValue(item);
if (pickerTarget === "darkVariant") {
if (objectValue) onChange({ ...objectValue, darkVariant: selected });
@@ -214,6 +258,113 @@ export function ImageFieldRenderer({
typeof value === "object" && value ? getMediaObjectPosition(value) : undefined;
const darkObjectPosition = darkValue ? getMediaObjectPosition(darkValue) : undefined;
const darkFilename = darkValue?.filename || t`Selected image`;
+ const canEditPrimaryAsset = Boolean(
+ objectValue?.id && canonicalMediaProviderId(objectValue.provider) === "local",
+ );
+ const canEditDarkAsset = Boolean(
+ darkValue?.id && canonicalMediaProviderId(darkValue.provider) === "local",
+ );
+ const primaryActions = (
+
+ }
+ onClick={() => openPicker("image")}
+ disabled={assetEditor.isActive}
+ >
+ {t`Replace`}
+
+ {canEditPrimaryAsset && (
+ }
+ loading={assetEditor.isOpening && pickerTarget === "image"}
+ onClick={(event) => {
+ setPickerTarget("image");
+ void assetEditor.openAssetEditor(objectValue!.id, event.currentTarget);
+ }}
+ >
+ {t`Edit asset`}
+
+ )}
+ }
+ onClick={handleRemove}
+ disabled={assetEditor.isActive}
+ aria-label={t`Remove image`}
+ >
+ {t`Remove`}
+
+
+ );
+ const mobileFeaturedActions = (
+
+ }
+ loading={assetEditor.isOpening && pickerTarget === "image"}
+ disabled={assetEditor.isActive}
+ aria-label={t`Image actions`}
+ aria-haspopup="menu"
+ aria-expanded={mobileActionsOpen}
+ />
+ }
+ />
+ }
+ />
+
+ }
+ onClick={() => openPicker("image")}
+ >
+ {t`Replace`}
+
+ {canEditPrimaryAsset && (
+ }
+ onClick={() => {
+ setPickerTarget("image");
+ void assetEditor.openAssetEditor(objectValue!.id, mobileImageActionsRef.current);
+ }}
+ >
+ {t`Edit asset`}
+
+ )}
+
+ }
+ onClick={handleRemove}
+ >
+ {t`Remove`}
+
+
+
+ );
const darkVariantSlot =
darkVariant && objectValue && displayUrl ? (
@@ -243,23 +394,41 @@ export function ImageFieldRenderer({
{darkFilename}
-
+
}
+ icon={
}
onClick={() => openPicker("darkVariant")}
- aria-label={t`Replace dark mode variant`}
+ disabled={assetEditor.isActive}
+ aria-label={t`Replace dark mode image`}
>
{t`Replace`}
+ {canEditDarkAsset && (
+
}
+ loading={assetEditor.isOpening && pickerTarget === "darkVariant"}
+ onClick={(event) => {
+ setPickerTarget("darkVariant");
+ void assetEditor.openAssetEditor(darkValue!.id, event.currentTarget);
+ }}
+ aria-label={t`Edit dark mode asset`}
+ >
+ {t`Edit asset`}
+
+ )}
}
+ icon={
}
onClick={handleRemoveDarkVariant}
+ disabled={assetEditor.isActive}
aria-label={t`Remove dark mode variant`}
>
{t`Remove`}
@@ -271,8 +440,9 @@ export function ImageFieldRenderer({
type="button"
size="sm"
variant="secondary"
- icon={
}
+ icon={
}
onClick={() => openPicker("darkVariant")}
+ disabled={assetEditor.isActive}
>
{t`Add dark mode variant`}
@@ -281,12 +451,15 @@ export function ImageFieldRenderer({
) : null;
const featuredCard = displayUrl ? (
-
-
+
+
{imageBroken ? (
-
+
-
+
{t`Image not found`}
@@ -300,39 +473,22 @@ export function ImageFieldRenderer({
/>
)}
-
-
-
- {selectedFilename}
-
- {metadata && (
-
- {metadata}
+
+
+
+
+ {selectedFilename}
- )}
-
-
- }
- onClick={() => openPicker("image")}
- >
- {t`Replace`}
-
- }
- onClick={handleRemove}
- aria-label={t`Remove image`}
- >
- {t`Remove`}
-
+ {metadata && (
+
+ {metadata}
+
+ )}
+
+ {primaryActions}
+ {mobileFeaturedActions}
) : null;
@@ -353,34 +509,15 @@ export function ImageFieldRenderer({
featuredCard
) : displayUrl ? (
imageBroken ? (
-
+
{t`Image not found`}
-
-
-
-
+ {primaryActions}
) : (
-
+

setImageBroken(true)}
/>
-
-
-
-
+ {primaryActions}
)
) : (
@@ -434,10 +552,29 @@ export function ImageFieldRenderer({
fieldId={fieldId}
title={
pickerTarget === "darkVariant"
- ? t`Select dark mode variant for ${label}`
- : t`Select ${label}`
+ ? darkDisplayUrl
+ ? t`Replace dark mode variant for ${label}`
+ : t`Select dark mode variant for ${label}`
+ : displayUrl
+ ? t`Replace ${label}`
+ : t`Select ${label}`
+ }
+ confirmLabel={
+ pickerTarget === "darkVariant"
+ ? darkDisplayUrl
+ ? t`Replace`
+ : undefined
+ : displayUrl
+ ? t`Replace`
+ : undefined
}
/>
+ {assetEditor.dialog}
+ {assetEditor.error && (
+
+ {assetEditor.error}
+
+ )}
{required && !displayUrl && (
{t`This field is required`}
)}
diff --git a/packages/admin/src/components/MediaDetailPanel.tsx b/packages/admin/src/components/MediaDetailPanel.tsx
index 7055eda37b..3095a97523 100644
--- a/packages/admin/src/components/MediaDetailPanel.tsx
+++ b/packages/admin/src/components/MediaDetailPanel.tsx
@@ -21,6 +21,7 @@ import {
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import {
+ ArrowLeft,
ArrowCounterClockwise,
ArrowsClockwise,
X,
@@ -138,39 +139,110 @@ interface MediaLocationOption {
export interface MediaDetailPanelProps {
open: boolean;
item: MediaItem;
+ embedded?: boolean;
+ context?: "library" | "content";
providerName?: string;
canDelete?: boolean;
canMoveLocation?: boolean;
canReplaceOriginal?: boolean;
canCropOriginal?: boolean;
canDuplicateCrop?: boolean;
+ requestExitRef?: React.MutableRefObject<(() => void) | null>;
restoreFocusTargetRef?: React.RefObject
;
onClose: () => void;
+ onExit?: () => void;
onClosed?: () => void;
onUpdated?: () => void;
onItemRefreshed?: (item: LocalMediaItem) => void;
- onCroppedCopyCreated?: () => void;
+ onCroppedCopyCreated?: (item: LocalMediaItem) => void;
+ onUnavailable?: (id: string) => void;
onDeleted?: () => void;
}
+interface MediaDetailRootProps {
+ embedded: boolean;
+ open: boolean;
+ isConfirmOpen: boolean;
+ onRequestClose: () => void;
+ onClosed: () => void;
+ children: React.ReactNode;
+}
+
+function MediaDetailRoot({
+ embedded,
+ open,
+ isConfirmOpen,
+ onRequestClose,
+ onClosed,
+ children,
+}: MediaDetailRootProps) {
+ if (embedded) return <>{children}>;
+ return (
+ {
+ if (!nextOpen && !isConfirmOpen) onRequestClose();
+ }}
+ onOpenChangeComplete={(nextOpen) => {
+ if (!nextOpen) onClosed();
+ }}
+ >
+ {children}
+
+ );
+}
+
+function MediaDetailSurface({
+ embedded,
+ children,
+}: {
+ embedded: boolean;
+ children: React.ReactNode;
+}) {
+ if (embedded) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ );
+}
+
/**
* Centered dialog for viewing and editing media metadata.
*/
export function MediaDetailPanel({
open,
item,
+ embedded = false,
+ context = "library",
providerName,
canDelete: canDeleteProp,
canMoveLocation: canMoveLocationProp,
canReplaceOriginal: canReplaceOriginalProp,
canCropOriginal = false,
canDuplicateCrop = false,
+ requestExitRef,
restoreFocusTargetRef,
onClose,
+ onExit,
onClosed,
onUpdated,
onItemRefreshed,
onCroppedCopyCreated,
+ onUnavailable,
onDeleted,
}: MediaDetailPanelProps) {
const { t } = useLingui();
@@ -184,6 +256,7 @@ export function MediaDetailPanel({
const closeFallbackTimerRef = React.useRef(null);
const closeFinishedRef = React.useRef(false);
const cropPendingRef = React.useRef(false);
+ const unavailableReportedRef = React.useRef(null);
const cropImageRef = React.useRef(null);
const dialogBodyRef = React.useRef(null);
const dialogResizeAnimationRef = React.useRef(null);
@@ -199,10 +272,10 @@ export function MediaDetailPanel({
// Present when the item streams rather than resolving to a playable file.
const playback = metaPlayback(item.meta);
const canEditMetadata = !isProviderAsset && isImage;
- const hasUsage = !isProviderAsset;
- const canDelete = !isProviderAsset || Boolean(canDeleteProp);
+ const hasUsage = context === "library" && !isProviderAsset;
+ const canDelete = context === "library" && (!isProviderAsset || Boolean(canDeleteProp));
const localItem = isLocalMediaItem(item) ? item : null;
- const canMoveLocation = Boolean(localItem && canMoveLocationProp);
+ const canMoveLocation = context === "library" && Boolean(localItem && canMoveLocationProp);
const canReplaceOriginal = canReplaceOriginalProp ?? canCropOriginal;
const cropMime = normalizeCropMime(item.mimeType);
const canShowCrop = Boolean(
@@ -252,6 +325,7 @@ export function MediaDetailPanel({
const [replacementImage, setReplacementImage] = React.useState(null);
const [replaceSelectionError, setReplaceSelectionError] = React.useState("");
const [replaceStatus, setReplaceStatus] = React.useState("");
+ const discardActionRef = React.useRef<"close" | "exit">("close");
const [pendingUsageEntry, setPendingUsageEntry] = React.useState(
null,
);
@@ -269,6 +343,7 @@ export function MediaDetailPanel({
replacePendingRef.current = false;
replaceSelectionTokenRef.current += 1;
cropPendingRef.current = false;
+ unavailableReportedRef.current = null;
cropImageRef.current = null;
if (imageModeOverflowFrameRef.current !== null) {
window.cancelAnimationFrame(imageModeOverflowFrameRef.current);
@@ -301,6 +376,7 @@ export function MediaDetailPanel({
setReplacementImage(null);
setReplaceSelectionError("");
setReplaceStatus("");
+ discardActionRef.current = "close";
setPendingUsageEntry(null);
}, [item.id, localItem?.folderId, open]);
@@ -383,14 +459,26 @@ export function MediaDetailPanel({
}
}, [onClosed, restoreFocusTargetRef]);
- const closeDialog = React.useCallback(() => {
- replaceSelectionTokenRef.current += 1;
- onClose();
- if (closeFallbackTimerRef.current !== null) {
- window.clearTimeout(closeFallbackTimerRef.current);
- }
- closeFallbackTimerRef.current = window.setTimeout(finishClose, CLOSE_FALLBACK_MS);
- }, [finishClose, onClose]);
+ const closeWith = React.useCallback(
+ (callback: () => void) => {
+ replaceSelectionTokenRef.current += 1;
+ callback();
+ if (embedded) {
+ finishClose();
+ return;
+ }
+ if (closeFallbackTimerRef.current !== null) {
+ window.clearTimeout(closeFallbackTimerRef.current);
+ }
+ closeFallbackTimerRef.current = window.setTimeout(finishClose, CLOSE_FALLBACK_MS);
+ },
+ [embedded, finishClose],
+ );
+ const closeDialog = React.useCallback(() => closeWith(onClose), [closeWith, onClose]);
+ const exitDialog = React.useCallback(
+ () => closeWith(onExit ?? onClose),
+ [closeWith, onClose, onExit],
+ );
const originalFocalPoint = normalizeMediaFocalPoint(item);
const focalPointChanged =
@@ -665,7 +753,7 @@ export function MediaDetailPanel({
onSuccess: ({ action, item: croppedItem }) => {
void queryClient.invalidateQueries({ queryKey: ["media"] });
if (action === "duplicate") {
- onCroppedCopyCreated?.();
+ onCroppedCopyCreated?.(croppedItem);
onUpdated?.();
setCropAspectMode("original");
setCropSelection(undefined);
@@ -686,9 +774,10 @@ export function MediaDetailPanel({
setShowCropConfirm(false);
setCropStatus(t`Original image cropped.`);
},
- onError: (_error, action) => {
+ onError: (error, action) => {
setCropStatus("");
if (action === "replace") setShowCropConfirm(false);
+ if (error instanceof ApiResponseError && error.code === "NOT_FOUND") recoverMediaItem();
},
onSettled: () => {
cropPendingRef.current = false;
@@ -706,6 +795,11 @@ export function MediaDetailPanel({
const mediaUnavailable =
recoverMediaMutation.error instanceof ApiResponseError &&
recoverMediaMutation.error.code === "NOT_FOUND";
+ React.useEffect(() => {
+ if (!open || !mediaUnavailable || unavailableReportedRef.current === item.id) return;
+ unavailableReportedRef.current = item.id;
+ onUnavailable?.(item.id);
+ }, [item.id, mediaUnavailable, onUnavailable, open]);
const isBusy = isSaving || isDeleting || isRecovering || isReplacing || isCropping;
const cropFooterActive = activeTab === "edit-image" && imageEditMode === "crop" && canShowCrop;
const cropActionDisabled =
@@ -731,6 +825,7 @@ export function MediaDetailPanel({
const requestClose = React.useCallback(() => {
if (isBusy) return;
if (isConfirmOpen) return;
+ discardActionRef.current = "close";
setPendingUsageEntry(null);
if (hasChanges) {
setShowDiscardConfirm(true);
@@ -738,6 +833,24 @@ export function MediaDetailPanel({
}
closeDialog();
}, [closeDialog, hasChanges, isBusy, isConfirmOpen]);
+ const requestExit = React.useCallback(() => {
+ if (isBusy) return;
+ if (isConfirmOpen) return;
+ discardActionRef.current = "exit";
+ setPendingUsageEntry(null);
+ if (hasChanges) {
+ setShowDiscardConfirm(true);
+ return;
+ }
+ exitDialog();
+ }, [exitDialog, hasChanges, isBusy, isConfirmOpen]);
+ React.useLayoutEffect(() => {
+ if (!requestExitRef) return;
+ requestExitRef.current = requestExit;
+ return () => {
+ if (requestExitRef.current === requestExit) requestExitRef.current = null;
+ };
+ }, [requestExit, requestExitRef]);
const handleSave = () => {
if (!canEdit || !hasChanges || isBusy || mediaUnavailable || savePendingRef.current) return;
@@ -931,7 +1044,8 @@ export function MediaDetailPanel({
const usageEntry = pendingUsageEntry;
setShowDiscardConfirm(false);
setPendingUsageEntry(null);
- closeDialog();
+ if (usageEntry || discardActionRef.current === "close") closeDialog();
+ else exitDialog();
if (usageEntry) {
void navigate({
to: "/content/$collection/$id",
@@ -953,6 +1067,7 @@ export function MediaDetailPanel({
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
+ discardActionRef.current = "close";
setPendingUsageEntry(entry);
setShowDiscardConfirm(true);
};
@@ -976,24 +1091,17 @@ export function MediaDetailPanel({
return (
<>
- {
- if (!nextOpen && !isConfirmOpen) requestClose();
- }}
- onOpenChangeComplete={(nextOpen) => {
- if (nextOpen) return;
- finishClose();
- }}
+ isConfirmOpen={isConfirmOpen}
+ onRequestClose={requestClose}
+ onClosed={finishClose}
>
-