diff --git a/.changeset/strong-media-dropzones.md b/.changeset/strong-media-dropzones.md new file mode 100644 index 000000000..6d7d5401a --- /dev/null +++ b/.changeset/strong-media-dropzones.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": minor +--- + +Add drag-and-drop uploads to the Media Library and media picker, and add inline alt text and caption editing in the media picker. diff --git a/packages/admin/src/components/MediaLibrary.tsx b/packages/admin/src/components/MediaLibrary.tsx index bda2223c1..5d4f0fca0 100644 --- a/packages/admin/src/components/MediaLibrary.tsx +++ b/packages/admin/src/components/MediaLibrary.tsx @@ -15,10 +15,28 @@ import { uploadToProvider, } from "../lib/api"; import { useDebouncedValue } from "../lib/hooks.js"; +import { + dataTransferFiles, + dataTransferHasFiles, + runUploadBatch, + type UploadBatchResult, +} from "../lib/media-upload-batch.js"; import { providerItemToMediaItem, getFileIcon, formatFileSize } from "../lib/media-utils"; +import { matchesMimeAllowlist, mimeFromFile } from "../lib/mime-utils.js"; import { cn } from "../lib/utils"; import { MediaDetailPanel } from "./MediaDetailPanel"; +const MEDIA_LIBRARY_UPLOAD_MIME_ALLOWLIST = [ + "image/", + "video/", + "audio/", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", +]; + /** Maps a coarse type-filter choice to the media list's `mimeType` filter. */ function mimeForTypeFilter(value: string): string | string[] | undefined { switch (value) { @@ -38,7 +56,7 @@ function mimeForTypeFilter(value: string): string | string[] | undefined { export interface MediaLibraryProps { items?: MediaItem[]; isLoading?: boolean; - onUpload?: (file: File) => Promise | void; + onUpload?: (file: File) => Promise; onSelect?: (item: MediaItem) => void; onDelete?: (id: string) => void; onItemUpdated?: () => void; @@ -52,6 +70,14 @@ export interface MediaLibraryProps { onLocalMimeFilterChange?: (mimeType: string | string[] | undefined) => void; } +type UploadState = { + status: "idle" | "uploading" | "success" | "error"; + message?: string; + progress?: { current: number; total: number }; +}; + +type DropState = "idle" | "active" | "reject"; + /** * Media library component with upload, provider tabs, and grid view */ @@ -79,11 +105,10 @@ export function MediaLibrary({ onLocalSearchChange(debouncedSearch.trim()); } }, [debouncedSearch, activeProvider, onLocalSearchChange]); - const [uploadState, setUploadState] = React.useState<{ - status: "idle" | "uploading" | "success" | "error"; - message?: string; - progress?: { current: number; total: number }; - }>({ status: "idle" }); + const [uploadState, setUploadState] = React.useState({ status: "idle" }); + const [dropState, setDropState] = React.useState("idle"); + const dragDepthRef = React.useRef(0); + const uploadInProgressRef = React.useRef(false); const fileInputRef = React.useRef(null); // Track loaded image dimensions for providers that don't return them (e.g., CF Images) const [loadedDimensions, setLoadedDimensions] = React.useState< @@ -123,6 +148,8 @@ export function MediaLibrary({ } return providers?.find((p) => p.id === activeProvider); }, [activeProvider, providers, t]); + const canUpload = activeProviderInfo?.capabilities.upload ?? false; + const canSearch = activeProviderInfo?.capabilities.search ?? false; // Update selected item when items change (e.g., after metadata update) React.useEffect(() => { @@ -147,93 +174,152 @@ export function MediaLibrary({ } }, [uploadState.status]); - const handleFileSelect = async (e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - const fileArray = [...files]; - const total = fileArray.length; - - if (activeProvider === "local") { - setUploadState({ status: "uploading", progress: { current: 0, total } }); - let uploaded = 0; - let failed = 0; - - for (const file of fileArray) { - try { - await onUpload?.(file); - uploaded++; - } catch (error) { - console.error("Upload failed:", error); - failed++; + const setFinalUploadState = React.useCallback( + (result: UploadBatchResult, rejectedBeforeUpload = 0) => { + const uploaded = result.uploaded.length; + const failed = result.failed.length + rejectedBeforeUpload; + const total = result.total + rejectedBeforeUpload; + + if (failed === 0) { + setUploadState({ + status: "success", + message: plural(total, { one: "File uploaded", other: "# files uploaded" }), + }); + } else if (uploaded === 0) { + setUploadState({ + status: "error", + message: plural(total, { one: "Upload failed", other: "All # uploads failed" }), + }); + } else { + setUploadState({ + status: "error", + message: t`${plural(uploaded, { one: "# file uploaded", other: "# files uploaded" })}, ${plural(failed, { one: "# file failed", other: "# files failed" })}`, + }); + } + }, + [t], + ); + + const handleFiles = React.useCallback( + async (files: File[]) => { + if (files.length === 0) return; + if (uploadInProgressRef.current) { + return; + } + if (!canUpload) { + setUploadState({ + status: "error", + message: t`Uploads are not available for this provider`, + }); + return; + } + + const acceptedFiles = files.filter((file) => { + const mime = mimeFromFile(file); + return mime ? matchesMimeAllowlist(mime, MEDIA_LIBRARY_UPLOAD_MIME_ALLOWLIST) : false; + }); + const rejectedCount = files.length - acceptedFiles.length; + + if (acceptedFiles.length === 0) { + setUploadState({ + status: "error", + message: plural(files.length, { + one: "The media library does not accept that file", + other: "The media library does not accept those # files", + }), + }); + return; + } + + uploadInProgressRef.current = true; + setUploadState({ + status: "uploading", + progress: { current: 0, total: acceptedFiles.length }, + }); + + try { + if (activeProvider === "local") { + if (!onUpload) { + setUploadState({ status: "error", message: t`Uploads are not configured` }); + return; } - setUploadState({ - status: "uploading", - progress: { current: uploaded + failed, total }, - }); - } - if (failed === 0) { - setUploadState({ - status: "success", - message: plural(total, { one: "File uploaded", other: "# files uploaded" }), + const result = await runUploadBatch(acceptedFiles, onUpload, (progress) => { + setUploadState({ status: "uploading", progress }); }); - } else if (uploaded === 0) { - setUploadState({ - status: "error", - message: plural(total, { one: "Upload failed", other: "All # uploads failed" }), - }); - } else { - setUploadState({ - status: "error", - message: t`${uploaded} uploaded, ${failed} failed`, - }); - } - } else if (activeProviderInfo?.capabilities.upload) { - // Upload to external provider - setUploadState({ status: "uploading", progress: { current: 0, total } }); - let uploaded = 0; - let failed = 0; - - for (const file of fileArray) { - try { - await uploadToProvider(activeProvider, file); - uploaded++; - } catch (error) { - console.error("Upload failed:", error); - failed++; + for (const failure of result.failed) { + console.error("Upload failed:", failure.error); } - setUploadState({ - status: "uploading", - progress: { current: uploaded + failed, total }, - }); + setFinalUploadState(result, rejectedCount); + return; } - if (failed === 0) { - setUploadState({ - status: "success", - message: plural(total, { one: "File uploaded", other: "# files uploaded" }), - }); - } else if (uploaded === 0) { - setUploadState({ - status: "error", - message: plural(total, { one: "Upload failed", other: "All # uploads failed" }), - }); - } else { - setUploadState({ - status: "error", - message: t`${uploaded} uploaded, ${failed} failed`, - }); + const result = await runUploadBatch( + acceptedFiles, + (file) => uploadToProvider(activeProvider, file), + (progress) => { + setUploadState({ status: "uploading", progress }); + }, + ); + for (const failure of result.failed) { + console.error("Upload failed:", failure.error); } - + setFinalUploadState(result, rejectedCount); void refetchProviderMedia(); + } finally { + uploadInProgressRef.current = false; } - } + }, + [activeProvider, canUpload, onUpload, refetchProviderMedia, setFinalUploadState, t], + ); + + const handleFileSelect = async (e: React.ChangeEvent) => { + await handleFiles([...(e.target.files ?? [])]); // Reset input if (fileInputRef.current) { fileInputRef.current.value = ""; } }; + const handleDragEnter = React.useCallback( + (e: React.DragEvent) => { + if (!dataTransferHasFiles(e.dataTransfer)) return; + e.preventDefault(); + dragDepthRef.current += 1; + setDropState(canUpload && uploadState.status !== "uploading" ? "active" : "reject"); + }, + [canUpload, uploadState.status], + ); + + const handleDragOver = React.useCallback( + (e: React.DragEvent) => { + if (!dataTransferHasFiles(e.dataTransfer)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = canUpload && uploadState.status !== "uploading" ? "copy" : "none"; + setDropState(canUpload && uploadState.status !== "uploading" ? "active" : "reject"); + }, + [canUpload, uploadState.status], + ); + + const handleDragLeave = React.useCallback((e: React.DragEvent) => { + if (!dataTransferHasFiles(e.dataTransfer)) return; + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setDropState("idle"); + } + }, []); + + const handleDrop = React.useCallback( + (e: React.DragEvent) => { + if (!dataTransferHasFiles(e.dataTransfer)) return; + e.preventDefault(); + dragDepthRef.current = 0; + setDropState("idle"); + void handleFiles(dataTransferFiles(e.dataTransfer)); + }, + [handleFiles], + ); + // Build provider tabs const providerTabs = React.useMemo(() => { const tabs: Array<{ id: string; name: string; icon?: string }> = [ @@ -254,11 +340,21 @@ export function MediaLibrary({ const currentProviderItems = activeProvider !== "local" ? providerData?.items || [] : []; const currentLoading = activeProvider === "local" ? isLoading : providerLoading; - const canUpload = activeProviderInfo?.capabilities.upload ?? false; - const canSearch = activeProviderInfo?.capabilities.search ?? false; + const dropMessage = + dropState === "active" + ? t`Drop files to upload to ${activeProviderInfo?.name ?? t`Library`}` + : dropState === "reject" + ? t`Uploads are not available here` + : t`Drag files here to upload`; return ( -
+
{/* Header */}

{t`Media Library`}

@@ -365,6 +461,25 @@ export function MediaLibrary({
+ {canUpload && ( +
+
+
+
+ )} + {/* Search — providers that support it, plus the local library (filename/extension search + type filter, handled server-side). */} {(canSearch || activeProvider === "local") && ( diff --git a/packages/admin/src/components/MediaPickerModal.tsx b/packages/admin/src/components/MediaPickerModal.tsx index ce4871202..cac73e908 100644 --- a/packages/admin/src/components/MediaPickerModal.tsx +++ b/packages/admin/src/components/MediaPickerModal.tsx @@ -6,7 +6,7 @@ * Used by the rich text editor and image field components. */ -import { Button, Dialog, Input, Label, Loader } from "@cloudflare/kumo"; +import { Button, Dialog, Input, InputArea, 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"; @@ -27,8 +27,14 @@ import { type MediaProviderItem, } from "../lib/api"; import { useDebouncedValue } from "../lib/hooks.js"; +import { + dataTransferFiles, + dataTransferHasFiles, + runUploadBatch, + type UploadBatchResult, +} from "../lib/media-upload-batch.js"; import { providerItemToMediaItem, getFileIcon } from "../lib/media-utils"; -import { matchesMimeAllowlist, mimeFromUrl } from "../lib/mime-utils.js"; +import { matchesMimeAllowlist, mimeFromFile, mimeFromUrl } from "../lib/mime-utils.js"; import { cn } from "../lib/utils"; import { DialogError } from "./DialogError.js"; @@ -38,6 +44,26 @@ interface SelectedMedia { item: MediaItem | MediaProviderItem; } +type UploadState = { + status: "idle" | "uploading" | "success" | "error"; + message?: string; + progress?: { current: number; total: number }; +}; + +type DropState = "idle" | "active" | "reject"; + +type MetadataDraft = { + alt: string; + caption: string; +}; + +type MediaQueryData = { + pages: { items: MediaItem[]; nextCursor?: string }[]; + pageParams: unknown[]; +}; + +type MediaQueryCache = MediaQueryData | { items: MediaItem[]; nextCursor?: string }; + /** * 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 @@ -155,6 +181,18 @@ export function MediaPickerModal({ const [imageUrl, setImageUrl] = React.useState(""); const [isProbing, setIsProbing] = React.useState(false); const [urlError, setUrlError] = React.useState(null); + const [uploadState, setUploadState] = React.useState({ status: "idle" }); + const [dropState, setDropState] = React.useState("idle"); + const dragDepthRef = React.useRef(0); + const uploadInProgressRef = React.useRef(false); + const modalSessionRef = React.useRef(0); + const [editingMetadataId, setEditingMetadataId] = React.useState(null); + const [metadataDraft, setMetadataDraft] = React.useState({ + alt: "", + caption: "", + }); + const [metadataError, setMetadataError] = React.useState(null); + const [metadataPendingSession, setMetadataPendingSession] = React.useState(null); // Track loaded image dimensions for providers that don't return them (e.g., CF Images) const [providerDimensions, setProviderDimensions] = React.useState< @@ -167,17 +205,34 @@ export function MediaPickerModal({ // (the tab UI is suppressed, but the selection state and provider-media // query would still target the external provider). React.useEffect(() => { + modalSessionRef.current += 1; + uploadInProgressRef.current = false; if (open) { setSelectedItem(null); setActiveProvider("local"); setSearchQuery(""); setImageUrl(""); setUrlError(null); - setUploadError(null); + setUploadState({ status: "idle" }); + setDropState("idle"); + dragDepthRef.current = 0; setProviderDimensions({}); + setEditingMetadataId(null); + setMetadataDraft({ alt: "", caption: "" }); + setMetadataError(null); + setMetadataPendingSession(null); } }, [open, localOnly]); + React.useEffect(() => { + if (uploadState.status === "success" || uploadState.status === "error") { + const timer = setTimeout(() => { + setUploadState({ status: "idle" }); + }, 3000); + return () => clearTimeout(timer); + } + }, [uploadState.status]); + // 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 @@ -195,13 +250,16 @@ export function MediaPickerModal({ if (activeProvider === "local") { return { id: "local", - name: "Library", + 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]); + }, [activeProvider, providers, t]); + const canUpload = + activeProvider === "local" || (activeProviderInfo?.capabilities.upload ?? false); + const canSearch = activeProviderInfo?.capabilities.search ?? false; // Fetch local media list (cursor-paginated so libraries beyond the // first page remain selectable from the picker, not just the first 50). @@ -246,18 +304,44 @@ export function MediaPickerModal({ const isLoading = activeProvider === "local" ? localLoading || isFetchingNextLocalPage : providerLoading; - const [uploadError, setUploadError] = React.useState(null); + const patchLocalMediaItem = React.useCallback( + (updated: MediaItem) => { + queryClient.setQueriesData({ queryKey: ["media"] }, (old: MediaQueryCache | undefined) => { + if (!old) return old; + if ("items" in old) { + return { + ...old, + items: old.items.map((item) => + item.id === updated.id ? { ...item, ...updated } : item, + ), + }; + } + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((item) => + item.id === updated.id ? { ...item, ...updated } : item, + ), + })), + }; + }); + + setSelectedItem((current) => { + if (current?.providerId !== "local" || current.item.id !== updated.id) { + return current; + } + return { providerId: "local", item: { ...(current.item as MediaItem), ...updated } }; + }); + }, + [queryClient], + ); // Upload mutation for local provider const uploadLocalMutation = useMutation({ mutationFn: (file: File) => uploadMedia(file, { fieldId }), - onSuccess: (item) => { + onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["media"] }); - setSelectedItem({ providerId: "local", item }); - setUploadError(null); - }, - onError: (err: Error) => { - setUploadError(err.message); }, }); @@ -265,17 +349,12 @@ export function MediaPickerModal({ const uploadProviderMutation = useMutation({ mutationFn: ({ providerId, file }: { providerId: string; file: File }) => uploadToProvider(providerId, file), - onSuccess: (item, { providerId }) => { + 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; + const isUploading = uploadState.status === "uploading"; // Track which items we've already updated dimensions for const updatedDimensionsRef = React.useRef>(new Set()); @@ -318,6 +397,37 @@ export function MediaPickerModal({ }, }); + const metadataMutation = useMutation({ + mutationFn: ({ + id, + alt, + caption, + }: { + id: string; + alt: string; + caption: string; + session: number; + }) => updateMedia(id, { alt, caption }), + onSuccess: (updated, { session }) => { + if (modalSessionRef.current !== session) return; + patchLocalMediaItem(updated); + setEditingMetadataId(null); + setMetadataDraft({ alt: "", caption: "" }); + setMetadataError(null); + }, + onError: (error, { session }) => { + if (modalSessionRef.current !== session) return; + setMetadataError(error instanceof Error ? error.message : t`Failed to update media`); + }, + onSettled: (_updated, _error, { session }) => { + if (modalSessionRef.current === session) { + setMetadataPendingSession(null); + } + }, + }); + const isMetadataSavePending = metadataPendingSession === modalSessionRef.current; + const canConfirmSelection = !isMetadataSavePending; + // Handle dimensions detected for local images missing them const handleDimensionsDetected = React.useCallback( (id: string, width: number, height: number) => { @@ -337,21 +447,224 @@ export function MediaPickerModal({ 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 }); + const setFinalUploadState = React.useCallback( + (result: UploadBatchResult, rejectedBeforeUpload = 0) => { + const uploaded = result.uploaded.length; + const failed = result.failed.length + rejectedBeforeUpload; + const total = result.total + rejectedBeforeUpload; + + if (failed === 0) { + setUploadState({ + status: "success", + message: plural(total, { one: "File uploaded", other: "# files uploaded" }), + }); + } else if (uploaded === 0) { + setUploadState({ + status: "error", + message: plural(total, { one: "Upload failed", other: "All # uploads failed" }), + }); + } else { + setUploadState({ + status: "error", + message: t`${plural(uploaded, { one: "# file uploaded", other: "# files uploaded" })}, ${plural(failed, { one: "# file failed", other: "# files failed" })}`, + }); } - } + }, + [t], + ); + + const handleFiles = React.useCallback( + async (files: File[]) => { + if (files.length === 0) return; + if (uploadInProgressRef.current) { + return; + } + if (!canUpload) { + setUploadState({ + status: "error", + message: t`Uploads are not available for this provider`, + }); + return; + } + + const acceptedFiles = + filters && filters.length > 0 + ? files.filter((file) => { + const mime = mimeFromFile(file); + return mime ? matchesMimeAllowlist(mime, filters) : false; + }) + : files; + const rejectedCount = files.length - acceptedFiles.length; + + if (acceptedFiles.length === 0) { + setUploadState({ + status: "error", + message: plural(files.length, { + one: "This picker does not accept that file", + other: "This picker does not accept those # files", + }), + }); + return; + } + + const uploadSession = modalSessionRef.current; + uploadInProgressRef.current = true; + setUploadState({ + status: "uploading", + progress: { current: 0, total: acceptedFiles.length }, + }); + + try { + if (activeProvider === "local") { + const result = await runUploadBatch( + acceptedFiles, + (file) => uploadLocalMutation.mutateAsync(file), + (progress) => { + if (modalSessionRef.current === uploadSession) { + setUploadState({ status: "uploading", progress }); + } + }, + ); + if (modalSessionRef.current !== uploadSession) return; + for (const failure of result.failed) { + console.error("Upload failed:", failure.error); + } + const lastUploaded = result.uploaded.at(-1); + if (lastUploaded) { + setSelectedItem({ providerId: "local", item: lastUploaded }); + } + setFinalUploadState(result, rejectedCount); + return; + } + + const result = await runUploadBatch( + acceptedFiles, + (file) => uploadProviderMutation.mutateAsync({ providerId: activeProvider, file }), + (progress) => { + if (modalSessionRef.current === uploadSession) { + setUploadState({ status: "uploading", progress }); + } + }, + ); + if (modalSessionRef.current !== uploadSession) return; + for (const failure of result.failed) { + console.error("Upload failed:", failure.error); + } + const lastUploaded = result.uploaded.at(-1); + if (lastUploaded) { + setSelectedItem({ providerId: activeProvider, item: lastUploaded }); + } + setFinalUploadState(result, rejectedCount); + } finally { + if (modalSessionRef.current === uploadSession) { + uploadInProgressRef.current = false; + } + } + }, + [ + activeProvider, + canUpload, + filters, + setFinalUploadState, + t, + uploadLocalMutation, + uploadProviderMutation, + ], + ); + + const handleFileSelect = (e: React.ChangeEvent) => { + void handleFiles([...(e.target.files ?? [])]); if (fileInputRef.current) { fileInputRef.current.value = ""; } }; + const handleDragEnter = React.useCallback( + (e: React.DragEvent) => { + if (!dataTransferHasFiles(e.dataTransfer)) return; + e.preventDefault(); + dragDepthRef.current += 1; + setDropState(canUpload && !isUploading ? "active" : "reject"); + }, + [canUpload, isUploading], + ); + + const handleDragOver = React.useCallback( + (e: React.DragEvent) => { + if (!dataTransferHasFiles(e.dataTransfer)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = canUpload && !isUploading ? "copy" : "none"; + setDropState(canUpload && !isUploading ? "active" : "reject"); + }, + [canUpload, isUploading], + ); + + const handleDragLeave = React.useCallback((e: React.DragEvent) => { + if (!dataTransferHasFiles(e.dataTransfer)) return; + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setDropState("idle"); + } + }, []); + + const handleDrop = React.useCallback( + (e: React.DragEvent) => { + if (!dataTransferHasFiles(e.dataTransfer)) return; + e.preventDefault(); + dragDepthRef.current = 0; + setDropState("idle"); + void handleFiles(dataTransferFiles(e.dataTransfer)); + }, + [handleFiles], + ); + + const selectedLocalItem = + selectedItem?.providerId === "local" ? (selectedItem.item as MediaItem) : null; + const selectedLocalImageMissingAlt = + !!selectedLocalItem && + selectedLocalItem.mimeType.startsWith("image/") && + !selectedLocalItem.alt?.trim(); + const isEditingSelectedMetadata = + !!selectedLocalItem && + selectedLocalItem.mimeType.startsWith("image/") && + editingMetadataId === selectedLocalItem.id; + + React.useEffect(() => { + if (editingMetadataId && editingMetadataId !== selectedLocalItem?.id) { + setEditingMetadataId(null); + setMetadataDraft({ alt: "", caption: "" }); + setMetadataError(null); + } + }, [editingMetadataId, selectedLocalItem?.id]); + + const openMetadataEditor = React.useCallback((item: MediaItem) => { + setEditingMetadataId(item.id); + setMetadataDraft({ alt: item.alt ?? "", caption: item.caption ?? "" }); + setMetadataError(null); + }, []); + + const handleMetadataSubmit = React.useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedLocalItem || editingMetadataId !== selectedLocalItem.id) return; + const metadataSession = modalSessionRef.current; + setMetadataPendingSession(metadataSession); + metadataMutation.mutate({ + id: selectedLocalItem.id, + alt: metadataDraft.alt.trim(), + caption: metadataDraft.caption.trim(), + session: metadataSession, + }); + }, + [ + editingMetadataId, + metadataDraft.alt, + metadataDraft.caption, + metadataMutation, + selectedLocalItem, + ], + ); + const handleConfirm = () => { if (selectedItem) { if (selectedItem.providerId === "local") { @@ -382,6 +695,14 @@ export function MediaPickerModal({ setSelectedItem(null); setImageUrl(""); setUrlError(null); + setUploadState({ status: "idle" }); + setDropState("idle"); + modalSessionRef.current += 1; + uploadInProgressRef.current = false; + setEditingMetadataId(null); + setMetadataDraft({ alt: "", caption: "" }); + setMetadataError(null); + setMetadataPendingSession(null); }; const handleUrlSubmit = async () => { @@ -444,17 +765,13 @@ export function MediaPickerModal({ } }; - 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: "Library", icon: undefined }, + { id: "local", name: t`Library`, icon: undefined }, ]; if (providers && !localOnly) { for (const p of providers) { @@ -464,283 +781,402 @@ export function MediaPickerModal({ } } return tabs; - }, [providers, localOnly]); + }, [providers, localOnly, t]); + + const dropMessage = + dropState === "active" + ? t`Drop files to upload to ${activeProviderInfo?.name ?? t`Library`}` + : dropState === "reject" + ? t`Uploads are not available here` + : t`Drag files here to upload`; + const uploadStatusMessage = + uploadState.status === "uploading" + ? uploadState.progress && uploadState.progress.total > 1 + ? t`Uploading ${uploadState.progress.current}/${uploadState.progress.total}...` + : t`Uploading...` + : uploadState.message; return ( -
- - {title} - - ( - - )} - /> -
+
+
+ + {title} + + ( + + )} + /> +
- {/* URL Input (image pickers only — probes image dimensions) */} - {!hideUrlInput && !localOnly && ( - <> -
- -
-
- - { - setImageUrl(e.target.value); - setUrlError(null); - }} - onKeyDown={handleUrlKeyDown} - className="ps-9" - /> + {/* URL Input (image pickers only — probes image dimensions) */} + {!hideUrlInput && !localOnly && ( + <> +
+ +
+
+ + { + setImageUrl(e.target.value); + setUrlError(null); + }} + onKeyDown={handleUrlKeyDown} + className="ps-9" + /> +
+
- + {urlError &&

{urlError}

}
- {urlError &&

{urlError}

} -
- {/* Divider with "or" */} -
-
- -
-
- {t`or choose from library`} + {/* 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 ? ( -
- + {/* Provider Tabs */} + {providerTabs.length > 1 && ( +
+ {providerTabs.map((tab) => ( + + ))}
- ) : items.length === 0 ? ( -
-
)} - {/* Load more (local library only — providers handle pagination internally) */} - {activeProvider === "local" && hasNextLocalPage && ( -
- + )} +
+ ) : ( +
    - {isFetchingNextLocalPage ? t`Loading...` : t`Load More`} + {activeProvider === "local" + ? (items as MediaItem[]).map((item) => ( + setSelectedItem({ providerId: "local", item })} + onDoubleClick={() => { + if (!canConfirmSelection) return; + onSelect(item); + onOpenChange(false); + }} + onDimensionsDetected={handleDimensionsDetected} + /> + )) + : (items as MediaProviderItem[]).map((item) => ( + setSelectedItem({ providerId: activeProvider, item })} + onDoubleClick={() => { + if (!canConfirmSelection) return; + // 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})`} + + )} + + {selectedLocalItem?.mimeType.startsWith("image/") && ( + + )} +
+ )} +
+ +
- )} -
- {/* Footer */} -
-
- {selectedItem && ( - - {t`Selected:`} {selectedItem.item.filename} - {selectedItem.providerId !== "local" && ( - - {t`(from ${providers?.find((p) => p.id === selectedItem.providerId)?.name})`} - - )} - + {isEditingSelectedMetadata && ( +
+
+

{t`Edit media metadata`}

+ +
+
+ + setMetadataDraft((draft) => ({ ...draft, alt: e.target.value })) + } + placeholder={t`Describe this image for accessibility`} + /> + + setMetadataDraft((draft) => ({ ...draft, caption: e.target.value })) + } + placeholder={t`Optional caption for display`} + rows={2} + /> +
+ +
+ +
+ )}
- -
@@ -765,6 +1201,17 @@ function MediaPickerItem({ const { t } = useLingui(); const isImage = item.mimeType.startsWith("image/"); const needsDimensions = isImage && (!item.width || !item.height); + const missingAlt = isImage && !item.alt?.trim(); + const hasAlt = isImage && !!item.alt?.trim(); + const hasCaption = !!item.caption?.trim(); + const ariaLabel = [ + item.filename, + missingAlt ? t`missing alt text` : hasAlt ? t`alt text set` : null, + hasCaption ? t`caption set` : null, + selected ? t`selected` : null, + ] + .filter(Boolean) + .join(", "); const handleImageLoad = React.useCallback( (e: React.SyntheticEvent) => { @@ -789,7 +1236,7 @@ function MediaPickerItem({ )} onClick={onClick} onDoubleClick={onDoubleClick} - aria-label={t`${item.filename}${selected ? t` (selected)` : ""}`} + aria-label={ariaLabel} > {isImage ? ( )} + {isImage && ( + + )} +