import { Button, Input, Loader } from "@cloudflare/kumo"; import { Upload, Image, SquaresFour, List, MagnifyingGlass, Check, X } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import * as React from "react"; import { type MediaItem, type MediaProviderInfo, type MediaProviderItem, fetchMediaProviders, fetchProviderMedia, uploadToProvider, } from "../lib/api"; import { providerItemToMediaItem, getFileIcon, formatFileSize } from "../lib/media-utils"; import { cn } from "../lib/utils"; import { MediaDetailPanel } from "./MediaDetailPanel"; export interface MediaLibraryProps { items?: MediaItem[]; isLoading?: boolean; onUpload?: (file: File) => Promise | void; onSelect?: (item: MediaItem) => void; onDelete?: (id: string) => void; onItemUpdated?: () => void; } /** * Media library component with upload, provider tabs, and grid view */ export function MediaLibrary({ items = [], isLoading, onUpload, onDelete, onItemUpdated, }: MediaLibraryProps) { const [viewMode, setViewMode] = React.useState<"grid" | "list">("grid"); const [selectedItem, setSelectedItem] = React.useState(null); const [activeProvider, setActiveProvider] = React.useState("local"); const [searchQuery, setSearchQuery] = React.useState(""); const [uploadState, setUploadState] = React.useState<{ status: "idle" | "uploading" | "success" | "error"; message?: string; progress?: { current: number; total: number }; }>({ status: "idle" }); 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< Record >({}); // Fetch available providers const { data: providers } = useQuery({ queryKey: ["media-providers"], queryFn: fetchMediaProviders, placeholderData: [], }); // Fetch provider media when a non-local provider is selected const { data: providerData, isLoading: providerLoading, refetch: refetchProviderMedia, } = useQuery({ queryKey: ["provider-media", activeProvider, searchQuery], queryFn: () => fetchProviderMedia(activeProvider, { limit: 50, query: searchQuery || undefined, }), enabled: activeProvider !== "local", }); // Get active provider info const activeProviderInfo = React.useMemo(() => { if (activeProvider === "local") { return { id: "local", name: "Library", capabilities: { browse: true, search: false, upload: true, delete: true }, } as MediaProviderInfo; } return providers?.find((p) => p.id === activeProvider); }, [activeProvider, providers]); // Update selected item when items change (e.g., after metadata update) React.useEffect(() => { if (selectedItem && activeProvider === "local") { const updated = items.find((i) => i.id === selectedItem.id); if (updated) { setSelectedItem(updated); } else { // Item was deleted setSelectedItem(null); } } }, [items, selectedItem?.id, activeProvider]); // Clear success/error message after a delay React.useEffect(() => { if (uploadState.status === "success" || uploadState.status === "error") { const timer = setTimeout(() => { setUploadState({ status: "idle" }); }, 3000); return () => clearTimeout(timer); } }, [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++; } setUploadState({ status: "uploading", progress: { current: uploaded + failed, total }, }); } if (failed === 0) { setUploadState({ status: "success", message: total === 1 ? "File uploaded" : `${total} files uploaded`, }); } else if (uploaded === 0) { setUploadState({ status: "error", message: total === 1 ? "Upload failed" : `All ${total} uploads failed`, }); } else { setUploadState({ status: "error", message: `${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++; } setUploadState({ status: "uploading", progress: { current: uploaded + failed, total }, }); } if (failed === 0) { setUploadState({ status: "success", message: total === 1 ? "File uploaded" : `${total} files uploaded`, }); } else if (uploaded === 0) { setUploadState({ status: "error", message: total === 1 ? "Upload failed" : `All ${total} uploads failed`, }); } else { setUploadState({ status: "error", message: `${uploaded} uploaded, ${failed} failed`, }); } void refetchProviderMedia(); } } // Reset input if (fileInputRef.current) { fileInputRef.current.value = ""; } }; // Build provider tabs const providerTabs = React.useMemo(() => { const tabs: Array<{ id: string; name: string; icon?: string }> = [ { id: "local", name: "Library", icon: undefined }, ]; if (providers) { for (const p of providers) { if (p.id !== "local") { tabs.push({ id: p.id, name: p.name, icon: p.icon }); } } } return tabs; }, [providers]); // Get current items based on active provider const currentItems = activeProvider === "local" ? items : []; const currentProviderItems = activeProvider !== "local" ? providerData?.items || [] : []; const currentLoading = activeProvider === "local" ? isLoading : providerLoading; const canUpload = activeProviderInfo?.capabilities.upload ?? false; const canSearch = activeProviderInfo?.capabilities.search ?? false; return (
{/* Header */}

Media Library

{/* Provider Tabs + Upload */}
{providerTabs.length > 1 && (
{providerTabs.map((tab) => ( ))}
)} {/* Upload button + status */}
{/* Upload status feedback */} {uploadState.status === "uploading" && (
Uploading {uploadState.progress && uploadState.progress.total > 1 && ` ${uploadState.progress.current}/${uploadState.progress.total}`} ...
)} {uploadState.status === "success" && (
{uploadState.message}
)} {uploadState.status === "error" && (
{uploadState.message}
)} {canUpload && ( <> )}
{/* Search (for providers that support it) */} {canSearch && (
setSearchQuery(e.target.value)} className="pl-9" />
)} {/* Content */} {currentLoading ? (
) : activeProvider === "local" && currentItems.length === 0 ? (

No media yet

Upload images, videos, and documents to get started.

) : activeProvider !== "local" && currentProviderItems.length === 0 ? (

No media found

{canSearch && searchQuery ? "Try a different search term" : canUpload ? "Upload media to get started" : "No media available from this provider"}

) : viewMode === "grid" ? (
{activeProvider === "local" ? currentItems.map((item) => ( setSelectedItem(item)} onDelete={() => onDelete?.(item.id)} /> )) : currentProviderItems.map((item) => ( { // Merge loaded dimensions if provider didn't return them const dims = loadedDimensions[item.id]; const itemWithDims = dims ? { ...item, width: item.width ?? dims.width, height: item.height ?? dims.height, } : item; setSelectedItem(providerItemToMediaItem(activeProvider, itemWithDims)); }} onDimensionsLoaded={(width, height) => { setLoadedDimensions((prev) => ({ ...prev, [item.id]: { width, height }, })); }} /> ))}
) : (
{activeProvider === "local" ? currentItems.map((item) => ( setSelectedItem(item)} onDelete={() => onDelete?.(item.id)} /> )) : currentProviderItems.map((item) => ( { const dims = loadedDimensions[item.id]; const itemWithDims = dims ? { ...item, width: item.width ?? dims.width, height: item.height ?? dims.height, } : item; setSelectedItem(providerItemToMediaItem(activeProvider, itemWithDims)); }} onDimensionsLoaded={(width, height) => { setLoadedDimensions((prev) => ({ ...prev, [item.id]: { width, height }, })); }} /> ))}
Preview Filename Type Size Actions
)} {/* Detail Panel */} {selectedItem && ( setSelectedItem(null)} onDeleted={() => { if (activeProvider === "local") { onDelete?.(selectedItem.id); onItemUpdated?.(); } else { void refetchProviderMedia(); } }} /> )}
); } interface MediaGridItemProps { item: MediaItem; selected?: boolean; onClick?: () => void; onDelete: () => void; } function MediaGridItem({ item, selected, onClick }: MediaGridItemProps) { const isImage = item.mimeType.startsWith("image/"); return ( ); } interface ProviderGridItemProps { item: MediaProviderItem; selected?: boolean; onClick?: () => void; /** Callback when image dimensions are loaded (for providers that don't return dimensions) */ onDimensionsLoaded?: (width: number, height: number) => void; } function ProviderGridItem({ item, selected, onClick, onDimensionsLoaded }: ProviderGridItemProps) { const isImage = item.mimeType.startsWith("image/"); const handleImageLoad = (e: React.SyntheticEvent) => { const img = e.currentTarget; // Only report if we don't already have dimensions if (onDimensionsLoaded && (!item.width || !item.height)) { onDimensionsLoaded(img.naturalWidth, img.naturalHeight); } }; return ( ); } interface MediaListItemProps { item: MediaItem; selected?: boolean; onClick?: () => void; onDelete: () => void; } function MediaListItem({ item, selected, onClick }: MediaListItemProps) { const isImage = item.mimeType.startsWith("image/"); return (
{isImage ? ( {item.alt ) : (
{getFileIcon(item.mimeType)}
)}
{item.filename} {item.mimeType} {formatFileSize(item.size)} {item.alt ? "Alt text set" : "No alt text"} ); } interface ProviderListItemProps { item: MediaProviderItem; selected?: boolean; onClick?: () => void; /** Callback when image dimensions are loaded (for providers that don't return dimensions) */ onDimensionsLoaded?: (width: number, height: number) => void; } function ProviderListItem({ item, selected, onClick, onDimensionsLoaded }: ProviderListItemProps) { const isImage = item.mimeType.startsWith("image/"); const handleImageLoad = (e: React.SyntheticEvent) => { const img = e.currentTarget; if (onDimensionsLoaded && (!item.width || !item.height)) { onDimensionsLoaded(img.naturalWidth, img.naturalHeight); } }; return (
{isImage && item.previewUrl ? ( {item.alt ) : (
{getFileIcon(item.mimeType)}
)}
{item.filename} {item.mimeType} {item.size ? formatFileSize(item.size) : "—"} {item.alt ? "Alt text set" : "No alt text"} ); } export default MediaLibrary;