import { useEffect, useId, useRef, useState, type ChangeEvent, type DragEvent as ReactDragEvent, type InputHTMLAttributes } from "react"; import { cn } from "../../lib/cn"; import { fieldNote, matchesAcceptedFileTypes, UploadPreviewChip, type FieldMeta, type UploadPreviewItem, } from "./shared"; export type FileUploadProps = FieldMeta & { accept?: string; acceptedFileTypes?: string[]; autoUpload?: boolean; className?: string; defaultUploadState?: "idle" | "error" | "success" | "uploading"; fileName?: string; maxFiles?: number; minFiles?: number; multiple?: boolean; onFileRemove?: (fileName: string, index: number) => void; onFilesChange?: (files: FileList) => void; onUploadCancel?: () => void; onChange?: InputHTMLAttributes["onChange"]; onUploadRequest?: (files: File[]) => Promise | void; onUploadRetry?: (files: File[]) => Promise | void; uploadProgress?: number; uploadState?: "idle" | "error" | "success" | "uploading"; uploadButtonLabel?: string; uploadingLabel?: string; }; export function FileUpload({ accept, acceptedFileTypes, autoUpload = true, className, defaultUploadState = "idle", fileName, maxFiles, minFiles, multiple, onFileRemove, onFilesChange, onUploadCancel, onChange, onUploadRequest, onUploadRetry, error, hint, label, uploadProgress, uploadState, uploadButtonLabel = "Upload files", uploadingLabel = "Uploading files", }: FileUploadProps) { const generatedId = useId(); const inputRef = useRef(null); const [dragActive, setDragActive] = useState(false); const [internalUploadState, setInternalUploadState] = useState(defaultUploadState); const [internalError, setInternalError] = useState(); const [selectedFiles, setSelectedFiles] = useState( fileName ? [{ name: fileName, size: 0, type: "" }] : [], ); const resolvedUploadState = uploadState ?? internalUploadState; const isUploading = resolvedUploadState === "uploading"; const resolvedAccept = accept ?? acceptedFileTypes?.join(","); const selectedCount = selectedFiles.length; const helperText = [ resolvedAccept ? `Allowed: ${resolvedAccept}` : null, minFiles ? `Min ${minFiles}` : null, maxFiles ? `Max ${maxFiles}` : null, multiple ? "Multiple files" : "Single file", autoUpload ? "Uploads automatically" : "Manual upload", ] .filter(Boolean) .join(" · "); useEffect(() => { return () => { selectedFiles.forEach((file) => { if (file.url) { URL.revokeObjectURL(file.url); } }); }; }, [selectedFiles]); async function runUpload(kind: "initial" | "retry") { if (selectedFiles.length === 0) { return; } const files = selectedFiles.map((file) => file.file).filter(Boolean) as File[]; if (files.length === 0) { return; } setInternalUploadState("uploading"); setInternalError(undefined); try { const action = kind === "retry" ? onUploadRetry ?? onUploadRequest : onUploadRequest; if (action) { await action(files); } setInternalUploadState("success"); } catch (uploadError) { setInternalUploadState("error"); setInternalError(uploadError instanceof Error ? uploadError.message : "Upload failed. Retry to continue."); } } function handleFiles(files: FileList | null) { if (!files || files.length === 0) { return; } if (isUploading) { return; } const nextFiles = Array.from(files); const normalizedFiles = multiple ? nextFiles : nextFiles.slice(0, 1); if (maxFiles && normalizedFiles.length > maxFiles) { setInternalUploadState("error"); setInternalError(`You can upload at most ${maxFiles} file${maxFiles === 1 ? "" : "s"}.`); return; } if (minFiles && normalizedFiles.length < minFiles) { setInternalUploadState("error"); setInternalError(`Select at least ${minFiles} file${minFiles === 1 ? "" : "s"}.`); return; } const invalidFile = normalizedFiles.find((file) => !matchesAcceptedFileTypes(file, acceptedFileTypes)); if (invalidFile) { setInternalUploadState("error"); setInternalError(`"${invalidFile.name}" is not an allowed file type.`); return; } setSelectedFiles((current) => { current.forEach((file) => { if (file.url) { URL.revokeObjectURL(file.url); } }); return normalizedFiles.map((file) => ({ file, name: file.name, size: file.size, type: file.type, url: file.type.startsWith("image/") || file.type === "application/pdf" ? URL.createObjectURL(file) : undefined, })); }); setInternalError(undefined); setInternalUploadState(autoUpload ? "uploading" : "idle"); onFilesChange?.(files); onChange?.({ target: { files }, currentTarget: { files }, } as ChangeEvent); if (autoUpload) { void Promise.resolve().then(() => runUpload("initial")); } } function handleDrop(event: ReactDragEvent) { event.preventDefault(); setDragActive(false); handleFiles(event.dataTransfer.files); } function removeFile(index: number) { if (isUploading) { return; } setSelectedFiles((current) => { const next = current.filter((_, itemIndex) => itemIndex !== index); const removed = current[index]; if (removed?.url) { URL.revokeObjectURL(removed.url); } onFileRemove?.(removed?.name ?? "", index); if (next.length === 0) { setInternalUploadState("idle"); setInternalError(undefined); } return next; }); } const selectedLabel = selectedFiles[0]?.name ?? fileName ?? "Choose a file to upload"; return (