/** * Image field with media picker * * Stores full image metadata including dimensions for responsive images. * Handles backwards compatibility with legacy string URLs. * * Extracted from ContentEditor so non-top-level field UIs (e.g. repeater * sub-fields) can reuse the same picker without a circular import. */ import { Button, Label } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { Image as ImageIcon, ImageBroken, X } from "@phosphor-icons/react"; import * as React from "react"; import type { MediaItem } from "../lib/api"; import { metaString } from "../lib/media-utils"; import { MediaPickerModal } from "./MediaPickerModal"; /** * Image field value - matches emdash's MediaValue type */ export interface ImageFieldValue { id: string; /** Provider ID (e.g., "local", "cloudflare-images") */ provider?: string; /** Direct URL for local media or legacy data */ src?: string; /** Preview URL for admin display (separate from src used for rendering) */ previewUrl?: string; alt?: string; width?: number; height?: number; /** LQIP blurhash placeholder (images only) */ blurhash?: string; /** LQIP dominant-color placeholder, as a CSS color (images only) */ dominantColor?: string; /** Provider-specific metadata */ meta?: Record; } export interface ImageFieldRendererProps { id?: string; label: string; description?: string; value: ImageFieldValue | string | undefined; onChange: (value: ImageFieldValue | null) => void; required?: boolean; allowedMimeTypes?: string[]; fieldId?: string; } export function ImageFieldRenderer({ id, label, description, value, onChange, required, allowedMimeTypes, fieldId, }: ImageFieldRendererProps) { const { t } = useLingui(); const [pickerOpen, setPickerOpen] = React.useState(false); const [imageBroken, setImageBroken] = React.useState(false); // Normalize value to get display URL (handles both object and legacy string) // Prefer previewUrl for admin display, fall back to src, then derive from storageKey/id const displayUrl = typeof value === "string" ? value : value?.previewUrl || value?.src || (value && (!value.provider || value.provider === "local") ? `/_emdash/api/media/file/${typeof value.meta?.storageKey === "string" ? value.meta.storageKey : value.id}` : undefined); React.useEffect(() => { setImageBroken(false); }, [displayUrl]); const handleSelect = (item: MediaItem) => { const isLocalProvider = !item.provider || item.provider === "local"; onChange({ id: item.id, provider: item.provider || "local", // Local media derives URLs from meta.storageKey at display time — no src needed // External providers cache a preview URL for admin display previewUrl: isLocalProvider ? undefined : item.url, alt: item.alt || "", width: item.width, height: item.height, // 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 handleRemove = () => { onChange(null); }; return (
{displayUrl ? ( imageBroken ? (
{t`Image not found`}
) : (
setImageBroken(true)} />
) ) : ( )} 0 ? allowedMimeTypes : ["image/"] } fieldId={fieldId} title={t`Select ${label}`} /> {description &&

{description}

} {required && !displayUrl && (

{t`This field is required`}

)}
); }