import { Button, Input, Loader, Select } from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; 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, MEDIA_SEARCH_MAX_LENGTH, fetchMediaProviders, fetchProviderMedia, uploadToProvider, } from "../lib/api"; import { useDebouncedValue } from "../lib/hooks.js"; import { providerItemToMediaItem, getFileIcon, formatFileSize, getMediaThumbnailUrl, fallbackToOriginalThumbnail, MEDIA_THUMBNAIL_WIDTH, } from "../lib/media-utils"; import { cn } from "../lib/utils"; import { MediaDetailPanel } from "./MediaDetailPanel"; /** Maps a coarse type-filter choice to the media list's `mimeType` filter. */ function mimeForTypeFilter(value: string): string | string[] | undefined { switch (value) { case "image": return "image/"; case "video": return "video/"; case "audio": return "audio/"; case "document": return ["application/", "text/"]; default: return undefined; } } export interface MediaLibraryProps { items?: MediaItem[]; isLoading?: boolean; onUpload?: (file: File) => Promise | void; onSelect?: (item: MediaItem) => void; onDelete?: (id: string) => void; onItemUpdated?: () => void; /** True when more local-library items can be fetched via cursor pagination */ hasMore?: boolean; /** Triggered to fetch the next page of local-library items */ onLoadMore?: () => void; /** Called (debounced) with the filename search term for the local library. */ onLocalSearchChange?: (q: string) => void; /** Called with the MIME filter for the local library (undefined = all types). */ onLocalMimeFilterChange?: (mimeType: string | string[] | undefined) => void; } /** * Media library component with upload, provider tabs, and grid view */ export function MediaLibrary({ items = [], isLoading, onUpload, onDelete, onItemUpdated, hasMore, onLoadMore, onLocalSearchChange, onLocalMimeFilterChange, }: MediaLibraryProps) { const { t } = useLingui(); 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 [localTypeFilter, setLocalTypeFilter] = React.useState("all"); // Debounced filename search reported up for the local library's server query. const debouncedSearch = useDebouncedValue(searchQuery, 300); React.useEffect(() => { if (activeProvider === "local" && onLocalSearchChange) { 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 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: t`Library`, capabilities: { browse: true, search: false, upload: true, delete: true }, } as MediaProviderInfo; } return providers?.find((p) => p.id === activeProvider); }, [activeProvider, providers, t]); // 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: 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`, }); } } 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: 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`, }); } 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: t`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, t]); // 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 */}

{t`Media Library`}

{/* Provider Tabs + Upload */}
{providerTabs.length > 1 && (
{providerTabs.map((tab) => ( ))}
)} {/* Upload button + status */}
{/* Upload status feedback */} {uploadState.status === "uploading" && (
{uploadState.progress && uploadState.progress.total > 1 ? t`Uploading ${uploadState.progress.current}/${uploadState.progress.total}...` : t`Uploading...`}
)} {uploadState.status === "success" && (
{uploadState.message}
)} {uploadState.status === "error" && (
{uploadState.message}
)} {canUpload && ( <> )}
{/* Search — providers that support it, plus the local library (filename/extension search + type filter, handled server-side). */} {(canSearch || activeProvider === "local") && (
setSearchQuery(e.target.value)} maxLength={MEDIA_SEARCH_MAX_LENGTH} className="ps-9" />
{activeProvider === "local" && (