/** * Media Picker Modal * * A modal dialog for selecting media from the library or uploading new files. * Supports multiple media providers with tabbed navigation. * Used by the rich text editor and image field components. */ import { Button, Dialog, Input, Label, Loader } from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { Upload, Image, Check, Globe, MagnifyingGlass, Paperclip } from "@phosphor-icons/react"; import { X } from "@phosphor-icons/react"; import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { MEDIA_SEARCH_MAX_LENGTH, fetchMediaList, fetchMediaProviders, fetchProviderMedia, uploadMedia, uploadToProvider, updateMedia, type MediaItem, type MediaProviderInfo, type MediaProviderItem, } from "../lib/api"; import { useDebouncedValue } from "../lib/hooks.js"; import { providerItemToMediaItem, getFileIcon, getMediaThumbnailUrl, fallbackToOriginalThumbnail, } from "../lib/media-utils"; import { matchesMimeAllowlist, mimeFromUrl } from "../lib/mime-utils.js"; import { cn } from "../lib/utils"; import { DialogError } from "./DialogError.js"; /** Selected item can be either a local MediaItem or a provider item with provider context */ interface SelectedMedia { providerId: string; item: MediaItem | MediaProviderItem; } /** * Returns true if the given MIME type matches any entry in the filters array. * Each filter entry is either an exact MIME type (e.g. "image/png") or a * type prefix ending with "/" (e.g. "image/"). */ function matchesAnyFilter(mime: string, filters: string[] | undefined): boolean { if (!filters || filters.length === 0) return true; const normalizedMime = mime.toLowerCase(); for (const entry of filters) { if (!entry || !entry.includes("/")) continue; const normalizedEntry = entry.toLowerCase(); if (normalizedEntry.endsWith("/")) { if (normalizedMime.startsWith(normalizedEntry)) return true; } else if (normalizedMime === normalizedEntry) { return true; } } return false; } export interface MediaPickerModalProps { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (item: MediaItem) => void; /** Filter by mime type prefix, e.g. "image/" */ mimeTypeFilter?: string; title?: string; /** * Hide the "Insert from URL" input. Defaults to false. * The URL input probes image dimensions and is only meaningful for image pickers, * so non-image pickers (e.g. generic file pickers) should hide it. */ hideUrlInput?: boolean; /** * What kind of media this picker is for. Drives user-facing copy * (default title, empty-state message, upload button label, empty-state icon). * Defaults to "image" — set to "file" for generic file pickers. */ mediaKind?: "image" | "file"; /** MIME allowlist — array of exact MIMEs or `type/` prefixes. */ mimeTypeFilters?: string[]; /** `_emdash_fields` row id for server-side MIME widening. */ fieldId?: string; /** * Restrict the picker to the local Library only — hides the "Insert from URL" * input and suppresses external provider tabs. * * Use this for fields whose storage model only persists a local `mediaId`. * Selecting an external URL or provider item would return an item the * server cannot later resolve back to a URL (the `id` is either empty * for "Insert from URL" or a provider-namespaced string that won't match * a row in the `media` table). Site settings (logo, favicon, * `seo.defaultOgImage`) are the canonical callers. */ localOnly?: boolean; } /** * Probe image URL to get dimensions */ function probeImageDimensions( url: string, errorMessage: string, ): Promise<{ width: number; height: number }> { return new Promise((resolve, reject) => { const img = new window.Image(); img.onload = () => { resolve({ width: img.naturalWidth, height: img.naturalHeight }); }; img.onerror = () => { reject(new Error(errorMessage)); }; img.src = url; }); } export function MediaPickerModal({ open, onOpenChange, onSelect, mimeTypeFilter = "image/", mimeTypeFilters, fieldId, title: providedTitle, hideUrlInput = false, mediaKind = "image", localOnly = false, }: MediaPickerModalProps) { const { t } = useLingui(); const isFileKind = mediaKind === "file"; // Unified filters: mimeTypeFilters (plural array) takes precedence over the // legacy mimeTypeFilter (singular string). const filters = React.useMemo(() => { if (mimeTypeFilters !== undefined) return mimeTypeFilters.length > 0 ? mimeTypeFilters : undefined; if (mimeTypeFilter && mimeTypeFilter.length > 0) return [mimeTypeFilter]; return undefined; }, [mimeTypeFilters, mimeTypeFilter]); const title = providedTitle ?? (isFileKind ? t`Select File` : t`Select Image`); const emptyStateUploadHint = isFileKind ? t`Upload a file to get started` : t`Upload an image to get started`; const emptyStateUploadCta = isFileKind ? t`Upload File` : t`Upload Image`; const EmptyStateIcon = isFileKind ? Paperclip : Image; const queryClient = useQueryClient(); const [selectedItem, setSelectedItem] = React.useState(null); const [activeProvider, setActiveProvider] = React.useState("local"); const [searchQuery, setSearchQuery] = React.useState(""); // Debounced for the local library's server-side filename search. const debouncedSearch = useDebouncedValue(searchQuery, 300); const fileInputRef = React.useRef(null); // URL input state const [imageUrl, setImageUrl] = React.useState(""); const [isProbing, setIsProbing] = React.useState(false); const [urlError, setUrlError] = React.useState(null); // Track loaded image dimensions for providers that don't return them (e.g., CF Images) const [providerDimensions, setProviderDimensions] = React.useState< Record >({}); // Reset state when modal opens, or when `localOnly` flips on while it's // already open. Without the `localOnly` dependency a parent that toggles // the prop mid-session could leave `activeProvider` on a non-local tab // (the tab UI is suppressed, but the selection state and provider-media // query would still target the external provider). React.useEffect(() => { if (open) { setSelectedItem(null); setActiveProvider("local"); setSearchQuery(""); setImageUrl(""); setUrlError(null); setUploadError(null); setProviderDimensions({}); } }, [open, localOnly]); // Fetch available providers — skipped when `localOnly` is set since the // list isn't used (provider tabs are suppressed and the active provider // stays "local"). Avoids a request to /providers on every modal open // when we'll just throw the result away. const { data: providers } = useQuery({ queryKey: ["media-providers"], queryFn: fetchMediaProviders, enabled: open && !localOnly, // Default to just local if fetch fails placeholderData: [], }); // Get active provider info const activeProviderInfo = React.useMemo(() => { if (activeProvider === "local") { return { id: "local", name: t`Library`, icon: undefined, capabilities: { browse: true, search: false, upload: true, delete: true }, } as MediaProviderInfo; } return providers?.find((p) => p.id === activeProvider); }, [activeProvider, providers, t]); // Fetch local media list (cursor-paginated so libraries beyond the // first page remain selectable from the picker, not just the first 50). // setQueryData is exact-match, so the optimistic dimension update below // must share this exact key with the query that populates it. const mediaQueryKey = ["media", filters?.join(",") ?? "", debouncedSearch.trim()]; const { data: localData, isLoading: localLoading, fetchNextPage: fetchNextLocalPage, hasNextPage: hasNextLocalPage, isFetchingNextPage: isFetchingNextLocalPage, } = useInfiniteQuery({ queryKey: mediaQueryKey, queryFn: ({ pageParam }) => fetchMediaList({ mimeType: filters, cursor: pageParam, limit: 100, search: debouncedSearch.trim() || undefined, }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, enabled: open && activeProvider === "local", }); // Fetch provider media list. Belt-and-suspenders: the reset effect // forces `activeProvider` back to "local" when `localOnly` is true, but // also gate this query directly so a stale render can't fire an // external request between state updates. const { data: providerData, isLoading: providerLoading } = useQuery({ queryKey: ["provider-media", activeProvider, filters?.join(",") ?? "", searchQuery], queryFn: () => fetchProviderMedia(activeProvider, { mimeType: filters, limit: 50, query: searchQuery || undefined, }), enabled: open && !localOnly && activeProvider !== "local", }); const isLoading = activeProvider === "local" ? localLoading || isFetchingNextLocalPage : providerLoading; const [uploadError, setUploadError] = React.useState(null); // Upload mutation for local provider const uploadLocalMutation = useMutation({ mutationFn: (file: File) => uploadMedia(file, { fieldId }), onSuccess: (item) => { void queryClient.invalidateQueries({ queryKey: ["media"] }); setSelectedItem({ providerId: "local", item }); setUploadError(null); }, onError: (err: Error) => { setUploadError(err.message); }, }); // Upload mutation for external providers const uploadProviderMutation = useMutation({ mutationFn: ({ providerId, file }: { providerId: string; file: File }) => uploadToProvider(providerId, file), onSuccess: (item, { providerId }) => { void queryClient.invalidateQueries({ queryKey: ["provider-media", providerId] }); setSelectedItem({ providerId, item }); setUploadError(null); }, onError: (err: Error) => { setUploadError(err.message); }, }); const isUploading = uploadLocalMutation.isPending || uploadProviderMutation.isPending; // Track which items we've already updated dimensions for const updatedDimensionsRef = React.useRef>(new Set()); // Mutation for updating media dimensions const dimensionsMutation = useMutation({ mutationFn: ({ id, width, height }: { id: string; width: number; height: number }) => updateMedia(id, { width, height }), onSuccess: (_updated, { id, width, height }) => { queryClient.setQueryData( mediaQueryKey, ( old: | { pages: { items: MediaItem[]; nextCursor?: string }[]; pageParams: unknown[]; } | undefined, ) => { if (!old) return old; return { ...old, pages: old.pages.map((page) => ({ ...page, items: page.items.map((item) => (item.id === id ? { ...item, width, height } : item)), })), }; }, ); if (selectedItem?.providerId === "local" && selectedItem.item.id === id) { setSelectedItem({ providerId: "local", item: { ...selectedItem.item, width, height }, }); } }, onError: (error) => { console.warn("Failed to update media dimensions:", error); }, }); // Handle dimensions detected for local images missing them const handleDimensionsDetected = React.useCallback( (id: string, width: number, height: number) => { if (updatedDimensionsRef.current.has(id)) return; updatedDimensionsRef.current.add(id); dimensionsMutation.mutate({ id, width, height }); }, [dimensionsMutation], ); // Get items for current view const items = React.useMemo(() => { if (activeProvider === "local") { const localItems = localData?.pages.flatMap((page) => page.items) || []; return localItems.filter((item) => matchesAnyFilter(item.mimeType, filters)); } return providerData?.items || []; }, [activeProvider, localData, providerData?.items, filters]); const handleFileSelect = (e: React.ChangeEvent) => { const files = e.target.files; const file = files?.[0]; if (file) { if (activeProvider === "local") { uploadLocalMutation.mutate(file); } else if (activeProviderInfo?.capabilities.upload) { uploadProviderMutation.mutate({ providerId: activeProvider, file }); } } if (fileInputRef.current) { fileInputRef.current.value = ""; } }; const handleConfirm = () => { if (selectedItem) { if (selectedItem.providerId === "local") { // When providerId is "local", item is always MediaItem onSelect(selectedItem.item as MediaItem); } else { // When providerId is not "local", item is always MediaProviderItem const providerItem = selectedItem.item as MediaProviderItem; const dims = providerDimensions[providerItem.id]; const itemWithDims = dims ? { ...providerItem, width: providerItem.width ?? dims.width, height: providerItem.height ?? dims.height, } : providerItem; const mediaItem = providerItemToMediaItem(selectedItem.providerId, itemWithDims); onSelect(mediaItem); } onOpenChange(false); setSelectedItem(null); setImageUrl(""); } }; const handleClose = () => { onOpenChange(false); setSelectedItem(null); setImageUrl(""); setUrlError(null); }; const handleUrlSubmit = async () => { if (!imageUrl.trim()) return; let url: URL; try { url = new URL(imageUrl.trim()); } catch { setUrlError(t`Please enter a valid URL`); return; } setIsProbing(true); setUrlError(null); try { const sniffedMime = mimeFromUrl(url) ?? "image/unknown"; // Pre-validate against the field's allowlist so the user sees the error // here rather than at content-save time (where it becomes INVALID_MIME_FOR_FIELD). if (sniffedMime === "image/unknown" && filters && filters.length > 0) { setUrlError( t`Cannot determine MIME type from URL. Use a URL ending in a recognized image extension (e.g. .jpg, .png, .webp).`, ); return; } if (filters && filters.length > 0 && !matchesMimeAllowlist(sniffedMime, filters)) { setUrlError(t`This field does not accept ${sniffedMime} files.`); return; } const dimensions = await probeImageDimensions(url.href, t`Failed to load image`); const externalItem: MediaItem = { id: "", filename: url.pathname.split("/").pop() || "external-image", mimeType: sniffedMime, url: url.href, provider: "external-url", size: 0, width: dimensions.width, height: dimensions.height, createdAt: new Date().toISOString(), }; onSelect(externalItem); onOpenChange(false); setImageUrl(""); } catch { setUrlError(t`Could not load image from URL`); } finally { setIsProbing(false); } }; const handleUrlKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); void handleUrlSubmit(); } }; const canUpload = activeProvider === "local" || (activeProviderInfo?.capabilities.upload ?? false); const canSearch = activeProviderInfo?.capabilities.search ?? false; // Build provider tabs - always show local first, then add external providers // Filter out "local" from API response since we add it manually. // When `localOnly` is set, suppress external providers entirely so the // picker can only return locally-stored media (see prop docs). const providerTabs = React.useMemo(() => { const tabs: Array<{ id: string; name: string; icon?: string }> = [ { id: "local", name: t`Library`, icon: undefined }, ]; if (providers && !localOnly) { for (const p of providers) { if (p.id !== "local") { tabs.push({ id: p.id, name: p.name, icon: p.icon }); } } } return tabs; }, [providers, localOnly, t]); return (
{title} ( )} />
{/* URL Input (image pickers only — probes image dimensions) */} {!hideUrlInput && !localOnly && ( <>
{ setImageUrl(e.target.value); setUrlError(null); }} onKeyDown={handleUrlKeyDown} className="ps-9" />
{urlError &&

{urlError}

}
{/* Divider with "or" */}
{t`or choose from library`}
)} {/* Provider Tabs */} {providerTabs.length > 1 && (
{providerTabs.map((tab) => ( ))}
)} {/* Toolbar */}
{/* Search — providers that support it, plus the local library (filename/extension search, handled server-side). */} {canSearch || activeProvider === "local" ? (
setSearchQuery(e.target.value)} maxLength={MEDIA_SEARCH_MAX_LENGTH} className="ps-9" />
) : (

{plural(items.length, { one: "# item", other: "# items" })}

)} {/* Upload button (if provider supports it) */} {canUpload && ( <> (f.endsWith("/") ? f + "*" : f)).join(",") : undefined } className="sr-only" onChange={handleFileSelect} aria-label={t`Upload file`} /> )}
{/* Upload error */} {/* Media Grid */}
{/* * Gate the centered loader on items being empty so that "Load More" * (which sets isLoading=true while fetching the next cursor page) * does not blank out already-rendered items / lose the user's * selection. Mirrors the ContentList pattern from #135. */} {isLoading && items.length === 0 ? (
) : items.length === 0 ? (
) : (
    {activeProvider === "local" ? (items as MediaItem[]).map((item) => ( setSelectedItem({ providerId: "local", item })} onDoubleClick={() => { onSelect(item); onOpenChange(false); }} onDimensionsDetected={handleDimensionsDetected} /> )) : (items as MediaProviderItem[]).map((item) => ( setSelectedItem({ providerId: activeProvider, item })} onDoubleClick={() => { // Merge loaded dimensions for double-click select const dims = providerDimensions[item.id]; const itemWithDims = dims ? { ...item, width: item.width ?? dims.width, height: item.height ?? dims.height, } : item; const mediaItem = providerItemToMediaItem(activeProvider, itemWithDims); onSelect(mediaItem); onOpenChange(false); }} onDimensionsLoaded={(width, height) => { setProviderDimensions((prev) => ({ ...prev, [item.id]: { width, height }, })); }} /> ))}
)} {/* Load more (local library only — providers handle pagination internally) */} {activeProvider === "local" && hasNextLocalPage && (
)}
{/* Footer */}
{selectedItem && ( {t`Selected:`} {selectedItem.item.filename} {selectedItem.providerId !== "local" && ( {t`(from ${providers?.find((p) => p.id === selectedItem.providerId)?.name})`} )} )}
); } interface MediaPickerItemProps { item: MediaItem; selected: boolean; onClick: () => void; onDoubleClick: () => void; onDimensionsDetected?: (id: string, width: number, height: number) => void; } function MediaPickerItem({ item, selected, onClick, onDoubleClick, onDimensionsDetected, }: MediaPickerItemProps) { const { t } = useLingui(); const isImage = item.mimeType.startsWith("image/"); const needsDimensions = isImage && (!item.width || !item.height); // Serve a resized thumbnail only when the original dimensions are already // known. When they're missing we display the original so `onLoad` can read // the true `naturalWidth`/`naturalHeight` to backfill them — a resized // rendition would report the thumbnail's dimensions and corrupt the record. const displayUrl = needsDimensions ? item.url : getMediaThumbnailUrl(item.url, item.mimeType); const handleImageLoad = React.useCallback( (e: React.SyntheticEvent) => { if (needsDimensions && onDimensionsDetected) { const img = e.currentTarget; if (img.naturalWidth && img.naturalHeight) { onDimensionsDetected(item.id, img.naturalWidth, img.naturalHeight); } } }, [needsDimensions, onDimensionsDetected, item.id], ); return (
  • ); } interface ProviderMediaItemProps { item: MediaProviderItem; selected: boolean; onClick: () => void; onDoubleClick: () => void; /** Callback when image dimensions are loaded (for providers that don't return dimensions) */ onDimensionsLoaded?: (width: number, height: number) => void; } function ProviderMediaItem({ item, selected, onClick, onDoubleClick, onDimensionsLoaded, }: ProviderMediaItemProps) { const { t } = useLingui(); const isImage = item.mimeType.startsWith("image/"); const needsDimensions = isImage && (!item.width || !item.height); const handleImageLoad = React.useCallback( (e: React.SyntheticEvent) => { if (needsDimensions && onDimensionsLoaded) { const img = e.currentTarget; if (img.naturalWidth && img.naturalHeight) { onDimensionsLoaded(img.naturalWidth, img.naturalHeight); } } }, [needsDimensions, onDimensionsLoaded], ); return (
  • ); } export default MediaPickerModal;