'use client'; import { FileArchiveIcon, FileAudioIcon, FileCodeIcon, FileCogIcon, FileIcon, FileTextIcon, FileVideoIcon, } from 'lucide-react'; import { Slot as SlotPrimitive } from '@radix-ui/react-slot'; import { useDirection as useDirectionPrimitive } from '@radix-ui/react-direction'; import * as React from 'react'; import { cn } from '@djangocfg/ui-core/lib'; import { useComposedRefs } from '@djangocfg/ui-core/lib'; import type { FileUploadProps, FileUploadContextValue, FileUploadItemContextValue, FileState, StoreState, StoreAction, Store, } from './types'; const ROOT_NAME = 'FileUpload'; const DROPZONE_NAME = 'FileUploadDropzone'; const TRIGGER_NAME = 'FileUploadTrigger'; const LIST_NAME = 'FileUploadList'; const ITEM_NAME = 'FileUploadItem'; const ITEM_PREVIEW_NAME = 'FileUploadItemPreview'; const ITEM_METADATA_NAME = 'FileUploadItemMetadata'; const ITEM_PROGRESS_NAME = 'FileUploadItemProgress'; const ITEM_DELETE_NAME = 'FileUploadItemDelete'; const CLEAR_NAME = 'FileUploadClear'; function formatBytes(bytes: number) { if (bytes === 0) return '0 B'; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / 1024 ** i).toFixed(i ? 1 : 0)} ${sizes[i]}`; } function getFileIcon(file: File) { const type = file.type; const extension = file.name.split('.').pop()?.toLowerCase() ?? ''; if (type.startsWith('video/')) { return ; } if (type.startsWith('audio/')) { return ; } if ( type.startsWith('text/') || ['txt', 'md', 'rtf', 'pdf'].includes(extension) ) { return ; } if ( [ 'html', 'css', 'js', 'jsx', 'ts', 'tsx', 'json', 'xml', 'php', 'py', 'rb', 'java', 'c', 'cpp', 'cs', ].includes(extension) ) { return ; } if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) { return ; } if ( ['exe', 'msi', 'app', 'apk', 'deb', 'rpm'].includes(extension) || type.startsWith('application/') ) { return ; } return ; } function useLazyRef(init: () => T): React.RefObject { const ref = React.useRef(null); if (ref.current === null) { ref.current = init(); } return ref as React.RefObject; } const StoreContext = React.createContext(null); function useStoreContext(consumerName: string) { const context = React.useContext(StoreContext); if (!context) { throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); } return context; } function useStore(selector: (state: StoreState) => T): T { const store = useStoreContext('useStore'); const lastValueRef = useLazyRef<{ value: T; state: StoreState } | null>( () => null, ); const getSnapshot = React.useCallback(() => { const state = store.getState(); const prevValue = lastValueRef.current; if (prevValue && prevValue.state === state) { return prevValue.value; } const nextValue = selector(state); lastValueRef.current = { value: nextValue, state }; return nextValue; }, [store, selector, lastValueRef]); return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); } const FileUploadContext = React.createContext( null, ); function useFileUploadContext(consumerName: string) { const context = React.useContext(FileUploadContext); if (!context) { throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); } return context; } export function FileUpload(props: FileUploadProps) { const { value, defaultValue, onValueChange, onAccept, onFileAccept, onFileReject, onFileValidate, onUpload, accept, maxFiles, maxSize, dir: dirProp, label, name, asChild, disabled = false, invalid = false, multiple = false, required = false, children, className, ...rootProps } = props; const inputId = React.useId(); const dropzoneId = React.useId(); const listId = React.useId(); const labelId = React.useId(); const dir = useDirectionPrimitive(dirProp); const listeners = useLazyRef(() => new Set<() => void>()).current; const files = useLazyRef>(() => new Map()).current; const urlCache = useLazyRef(() => new WeakMap()).current; const inputRef = React.useRef(null); const isControlled = value !== undefined; const propsRef = React.useRef({ onValueChange, onAccept, onFileAccept, onFileReject, onFileValidate, onUpload, }); React.useEffect(() => { propsRef.current = { onValueChange, onAccept, onFileAccept, onFileReject, onFileValidate, onUpload, }; }); const store = React.useMemo(() => { let state: StoreState = { files, dragOver: false, invalid: invalid, }; function reducer(state: StoreState, action: StoreAction): StoreState { switch (action.type) { case 'ADD_FILES': { for (const file of action.files) { files.set(file, { file, progress: 0, status: 'idle', }); } if (propsRef.current.onValueChange) { const fileList = Array.from(files.values()).map( (fileState) => fileState.file, ); propsRef.current.onValueChange(fileList); } return { ...state, files }; } case 'SET_FILES': { const newFileSet = new Set(action.files); for (const existingFile of files.keys()) { if (!newFileSet.has(existingFile)) { files.delete(existingFile); } } for (const file of action.files) { const existingState = files.get(file); if (!existingState) { files.set(file, { file, progress: 0, status: 'idle', }); } } return { ...state, files }; } case 'SET_PROGRESS': { const fileState = files.get(action.file); if (fileState) { files.set(action.file, { ...fileState, progress: action.progress, status: 'uploading', }); } return { ...state, files }; } case 'SET_SUCCESS': { const fileState = files.get(action.file); if (fileState) { files.set(action.file, { ...fileState, progress: 100, status: 'success', }); } return { ...state, files }; } case 'SET_ERROR': { const fileState = files.get(action.file); if (fileState) { files.set(action.file, { ...fileState, error: action.error, status: 'error', }); } return { ...state, files }; } case 'REMOVE_FILE': { const cachedUrl = urlCache.get(action.file); if (cachedUrl) { URL.revokeObjectURL(cachedUrl); urlCache.delete(action.file); } files.delete(action.file); if (propsRef.current.onValueChange) { const fileList = Array.from(files.values()).map( (fileState) => fileState.file, ); propsRef.current.onValueChange(fileList); } return { ...state, files }; } case 'SET_DRAG_OVER': { return { ...state, dragOver: action.dragOver }; } case 'SET_INVALID': { return { ...state, invalid: action.invalid }; } case 'CLEAR': { for (const file of files.keys()) { const cachedUrl = urlCache.get(file); if (cachedUrl) { URL.revokeObjectURL(cachedUrl); } } files.clear(); if (propsRef.current.onValueChange) { propsRef.current.onValueChange([]); } return { ...state, files, invalid: false }; } default: return state; } } return { getState: () => state, dispatch: (action) => { state = reducer(state, action); for (const listener of listeners) { listener(); } }, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, }; }, [listeners, files, invalid, urlCache]); const acceptTypes = React.useMemo( () => accept?.split(',').map((t) => t.trim()) ?? null, [accept], ); const onProgress = useLazyRef(() => { let frame = 0; return (file: File, progress: number) => { if (frame) return; frame = requestAnimationFrame(() => { frame = 0; store.dispatch({ type: 'SET_PROGRESS', file, progress: Math.min(Math.max(0, progress), 100), }); }); }; }).current; React.useEffect(() => { if (isControlled) { store.dispatch({ type: 'SET_FILES', files: value }); } else if ( defaultValue && defaultValue.length > 0 && !store.getState().files.size ) { store.dispatch({ type: 'SET_FILES', files: defaultValue }); } }, [value, defaultValue, isControlled, store]); React.useEffect(() => { return () => { for (const file of files.keys()) { const cachedUrl = urlCache.get(file); if (cachedUrl) { URL.revokeObjectURL(cachedUrl); } } }; }, [files, urlCache]); const onFilesUpload = React.useCallback( async (files: File[]) => { try { for (const file of files) { store.dispatch({ type: 'SET_PROGRESS', file, progress: 0 }); } if (propsRef.current.onUpload) { await propsRef.current.onUpload(files, { onProgress, onSuccess: (file) => { store.dispatch({ type: 'SET_SUCCESS', file }); }, onError: (file, error) => { store.dispatch({ type: 'SET_ERROR', file, error: error.message ?? 'Upload failed', }); }, }); } else { for (const file of files) { store.dispatch({ type: 'SET_SUCCESS', file }); } } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Upload failed'; for (const file of files) { store.dispatch({ type: 'SET_ERROR', file, error: errorMessage, }); } } }, [store, onProgress], ); const onFilesChange = React.useCallback( (originalFiles: File[]) => { if (disabled) return; let filesToProcess = [...originalFiles]; let invalid = false; if (maxFiles) { const currentCount = store.getState().files.size; const remainingSlotCount = Math.max(0, maxFiles - currentCount); if (remainingSlotCount < filesToProcess.length) { const rejectedFiles = filesToProcess.slice(remainingSlotCount); invalid = true; filesToProcess = filesToProcess.slice(0, remainingSlotCount); for (const file of rejectedFiles) { let rejectionMessage = `Maximum ${maxFiles} files allowed`; if (propsRef.current.onFileValidate) { const validationMessage = propsRef.current.onFileValidate(file); if (validationMessage) { rejectionMessage = validationMessage; } } propsRef.current.onFileReject?.(file, rejectionMessage); } } } const acceptedFiles: File[] = []; const rejectedFiles: { file: File; message: string }[] = []; for (const file of filesToProcess) { let rejected = false; let rejectionMessage = ''; if (propsRef.current.onFileValidate) { const validationMessage = propsRef.current.onFileValidate(file); if (validationMessage) { rejectionMessage = validationMessage; propsRef.current.onFileReject?.(file, rejectionMessage); rejected = true; invalid = true; continue; } } if (acceptTypes) { const fileType = file.type; const fileExtension = `.${file.name.split('.').pop()}`; if ( !acceptTypes.some( (type) => type === fileType || type === fileExtension || (type.includes('/*') && fileType.startsWith(type.replace('/*', '/'))), ) ) { rejectionMessage = 'File type not accepted'; propsRef.current.onFileReject?.(file, rejectionMessage); rejected = true; invalid = true; } } if (maxSize && file.size > maxSize) { rejectionMessage = 'File too large'; propsRef.current.onFileReject?.(file, rejectionMessage); rejected = true; invalid = true; } if (!rejected) { acceptedFiles.push(file); } else { rejectedFiles.push({ file, message: rejectionMessage }); } } if (invalid) { store.dispatch({ type: 'SET_INVALID', invalid }); setTimeout(() => { store.dispatch({ type: 'SET_INVALID', invalid: false }); }, 2000); } if (acceptedFiles.length > 0) { store.dispatch({ type: 'ADD_FILES', files: acceptedFiles }); if (isControlled && propsRef.current.onValueChange) { const currentFiles = Array.from(store.getState().files.values()).map( (f) => f.file, ); propsRef.current.onValueChange([...currentFiles]); } if (propsRef.current.onAccept) { propsRef.current.onAccept(acceptedFiles); } for (const file of acceptedFiles) { propsRef.current.onFileAccept?.(file); } if (propsRef.current.onUpload) { requestAnimationFrame(() => { onFilesUpload(acceptedFiles); }); } } }, [ store, isControlled, onFilesUpload, maxFiles, acceptTypes, maxSize, disabled, ], ); const onInputChange = React.useCallback( (event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []); onFilesChange(files); event.target.value = ''; }, [onFilesChange], ); const contextValue = React.useMemo( () => ({ dropzoneId, inputId, listId, labelId, dir, disabled, inputRef, urlCache, }), [dropzoneId, inputId, listId, labelId, dir, disabled, urlCache], ); const RootPrimitive = asChild ? SlotPrimitive : 'div'; return ( {children}
{label ?? 'File upload'}
); } FileUpload.displayName = ROOT_NAME; export function FileUploadDropzone(props: React.ComponentProps<'div'> & { asChild?: boolean }) { const { asChild, className, onClick: onClickProp, onDragOver: onDragOverProp, onDragEnter: onDragEnterProp, onDragLeave: onDragLeaveProp, onDrop: onDropProp, onPaste: onPasteProp, onKeyDown: onKeyDownProp, ...dropzoneProps } = props; const context = useFileUploadContext(DROPZONE_NAME); const store = useStoreContext(DROPZONE_NAME); const dragOver = useStore((state) => state.dragOver); const invalid = useStore((state) => state.invalid); const propsRef = React.useRef({ onClick: onClickProp, onDragOver: onDragOverProp, onDragEnter: onDragEnterProp, onDragLeave: onDragLeaveProp, onDrop: onDropProp, onPaste: onPasteProp, onKeyDown: onKeyDownProp, }); React.useEffect(() => { propsRef.current = { onClick: onClickProp, onDragOver: onDragOverProp, onDragEnter: onDragEnterProp, onDragLeave: onDragLeaveProp, onDrop: onDropProp, onPaste: onPasteProp, onKeyDown: onKeyDownProp, }; }); const onClick = React.useCallback( (event: React.MouseEvent) => { propsRef.current.onClick?.(event); if (event.defaultPrevented) return; const target = event.target; const isFromTrigger = target instanceof HTMLElement && target.closest('[data-slot="file-upload-trigger"]'); if (!isFromTrigger) { context.inputRef.current?.click(); } }, [context.inputRef], ); const onDragOver = React.useCallback( (event: React.DragEvent) => { propsRef.current.onDragOver?.(event); if (event.defaultPrevented) return; event.preventDefault(); store.dispatch({ type: 'SET_DRAG_OVER', dragOver: true }); }, [store], ); const onDragEnter = React.useCallback( (event: React.DragEvent) => { propsRef.current.onDragEnter?.(event); if (event.defaultPrevented) return; event.preventDefault(); store.dispatch({ type: 'SET_DRAG_OVER', dragOver: true }); }, [store], ); const onDragLeave = React.useCallback( (event: React.DragEvent) => { propsRef.current.onDragLeave?.(event); if (event.defaultPrevented) return; const relatedTarget = event.relatedTarget; if ( relatedTarget && relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget) ) { return; } event.preventDefault(); store.dispatch({ type: 'SET_DRAG_OVER', dragOver: false }); }, [store], ); const onDrop = React.useCallback( (event: React.DragEvent) => { propsRef.current.onDrop?.(event); if (event.defaultPrevented) return; event.preventDefault(); store.dispatch({ type: 'SET_DRAG_OVER', dragOver: false }); const files = Array.from(event.dataTransfer.files); const inputElement = context.inputRef.current; if (!inputElement) return; const dataTransfer = new DataTransfer(); for (const file of files) { dataTransfer.items.add(file); } inputElement.files = dataTransfer.files; inputElement.dispatchEvent(new Event('change', { bubbles: true })); }, [store, context.inputRef], ); const onPaste = React.useCallback( (event: React.ClipboardEvent) => { propsRef.current.onPaste?.(event); if (event.defaultPrevented) return; event.preventDefault(); store.dispatch({ type: 'SET_DRAG_OVER', dragOver: false }); const items = event.clipboardData?.items; if (!items) return; const files: File[] = []; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item?.kind === 'file') { const file = item.getAsFile(); if (file) { files.push(file); } } } if (files.length === 0) return; const inputElement = context.inputRef.current; if (!inputElement) return; const dataTransfer = new DataTransfer(); for (const file of files) { dataTransfer.items.add(file); } inputElement.files = dataTransfer.files; inputElement.dispatchEvent(new Event('change', { bubbles: true })); }, [store, context.inputRef], ); const onKeyDown = React.useCallback( (event: React.KeyboardEvent) => { propsRef.current.onKeyDown?.(event); if ( !event.defaultPrevented && (event.key === 'Enter' || event.key === ' ') ) { event.preventDefault(); context.inputRef.current?.click(); } }, [context.inputRef], ); const DropzonePrimitive = asChild ? SlotPrimitive : 'div'; return ( ); } FileUploadDropzone.displayName = DROPZONE_NAME; export function FileUploadTrigger(props: React.ComponentProps<'button'> & { asChild?: boolean }) { const { asChild, onClick: onClickProp, ...triggerProps } = props; const context = useFileUploadContext(TRIGGER_NAME); const propsRef = React.useRef({ onClick: onClickProp }); React.useEffect(() => { propsRef.current = { onClick: onClickProp }; }); const onClick = React.useCallback( (event: React.MouseEvent) => { propsRef.current.onClick?.(event); if (event.defaultPrevented) return; context.inputRef.current?.click(); }, [context.inputRef], ); const TriggerPrimitive = asChild ? SlotPrimitive : 'button'; return ( ); } FileUploadTrigger.displayName = TRIGGER_NAME; export function FileUploadList(props: React.ComponentProps<'div'> & { orientation?: 'horizontal' | 'vertical'; asChild?: boolean; forceMount?: boolean }) { const { className, orientation = 'vertical', asChild, forceMount, ...listProps } = props; const context = useFileUploadContext(LIST_NAME); const fileCount = useStore((state) => state.files.size); const shouldRender = forceMount || fileCount > 0; if (!shouldRender) return null; const ListPrimitive = asChild ? SlotPrimitive : 'div'; return ( ); } FileUploadList.displayName = LIST_NAME; const FileUploadItemContext = React.createContext(null); function useFileUploadItemContext(consumerName: string) { const context = React.useContext(FileUploadItemContext); if (!context) { throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``); } return context; } export function FileUploadItem(props: React.ComponentProps<'div'> & { value: File; asChild?: boolean }) { const { value, asChild, className, ...itemProps } = props; const id = React.useId(); const statusId = `${id}-status`; const nameId = `${id}-name`; const sizeId = `${id}-size`; const messageId = `${id}-message`; const context = useFileUploadContext(ITEM_NAME); const fileState = useStore((state) => state.files.get(value)); const fileCount = useStore((state) => state.files.size); const fileIndex = useStore((state) => { const files = Array.from(state.files.keys()); return files.indexOf(value) + 1; }); const itemContext = React.useMemo( () => ({ id, fileState, nameId, sizeId, statusId, messageId, }), [id, fileState, statusId, nameId, sizeId, messageId], ); if (!fileState) return null; const statusText = fileState.error ? `Error: ${fileState.error}` : fileState.status === 'uploading' ? `Uploading: ${fileState.progress}% complete` : fileState.status === 'success' ? 'Upload complete' : 'Ready to upload'; const ItemPrimitive = asChild ? SlotPrimitive : 'div'; return ( {props.children} {statusText} ); } FileUploadItem.displayName = ITEM_NAME; export function FileUploadItemPreview(props: React.ComponentProps<'div'> & { render?: (file: File, fallback: () => React.ReactNode) => React.ReactNode; asChild?: boolean }) { const { render, asChild, children, className, ...previewProps } = props; const itemContext = useFileUploadItemContext(ITEM_PREVIEW_NAME); const context = useFileUploadContext(ITEM_PREVIEW_NAME); const getDefaultRender = React.useCallback( (file: File) => { if (itemContext.fileState?.file.type.startsWith('image/')) { let url = context.urlCache.get(file); if (!url) { url = URL.createObjectURL(file); context.urlCache.set(file, url); } return ( {file.name} ); } return getFileIcon(file); }, [itemContext.fileState?.file.type, context.urlCache], ); const onPreviewRender = React.useCallback( (file: File) => { if (render) { return render(file, () => getDefaultRender(file)); } return getDefaultRender(file); }, [render, getDefaultRender], ); if (!itemContext.fileState) return null; const ItemPreviewPrimitive = asChild ? SlotPrimitive : 'div'; return ( svg]:size-10', className, )} > {onPreviewRender(itemContext.fileState.file)} {children} ); } FileUploadItemPreview.displayName = ITEM_PREVIEW_NAME; export function FileUploadItemMetadata(props: React.ComponentProps<'div'> & { asChild?: boolean; size?: 'default' | 'sm' }) { const { asChild, size = 'default', children, className, ...metadataProps } = props; const context = useFileUploadContext(ITEM_METADATA_NAME); const itemContext = useFileUploadItemContext(ITEM_METADATA_NAME); if (!itemContext.fileState) return null; const ItemMetadataPrimitive = asChild ? SlotPrimitive : 'div'; return ( {children ?? ( <> {itemContext.fileState.file.name} {formatBytes(itemContext.fileState.file.size)} {itemContext.fileState.error && ( {itemContext.fileState.error} )} )} ); } FileUploadItemMetadata.displayName = ITEM_METADATA_NAME; export function FileUploadItemProgress(props: React.ComponentProps<'div'> & { variant?: 'linear' | 'circular' | 'fill'; size?: number; asChild?: boolean; forceMount?: boolean }) { const { variant = 'linear', size = 40, asChild, forceMount, className, ...progressProps } = props; const itemContext = useFileUploadItemContext(ITEM_PROGRESS_NAME); if (!itemContext.fileState) return null; const shouldRender = forceMount || itemContext.fileState.progress !== 100; if (!shouldRender) return null; const ItemProgressPrimitive = asChild ? SlotPrimitive : 'div'; switch (variant) { case 'circular': { const circumference = 2 * Math.PI * ((size - 4) / 2); const strokeDashoffset = circumference - (itemContext.fileState.progress / 100) * circumference; return ( ); } case 'fill': { const progressPercentage = itemContext.fileState.progress; const topInset = 100 - progressPercentage; return ( ); } default: return (
); } } FileUploadItemProgress.displayName = ITEM_PROGRESS_NAME; export function FileUploadItemDelete(props: React.ComponentProps<'button'> & { asChild?: boolean }) { const { asChild, onClick: onClickProp, ...deleteProps } = props; const store = useStoreContext(ITEM_DELETE_NAME); const itemContext = useFileUploadItemContext(ITEM_DELETE_NAME); const onClick = React.useCallback( (event: React.MouseEvent) => { onClickProp?.(event); if (!itemContext.fileState || event.defaultPrevented) return; store.dispatch({ type: 'REMOVE_FILE', file: itemContext.fileState.file, }); }, [store, itemContext.fileState, onClickProp], ); if (!itemContext.fileState) return null; const ItemDeletePrimitive = asChild ? SlotPrimitive : 'button'; return ( ); } FileUploadItemDelete.displayName = ITEM_DELETE_NAME; export function FileUploadClear(props: React.ComponentProps<'button'> & { forceMount?: boolean; asChild?: boolean }) { const { asChild, forceMount, disabled, onClick: onClickProp, ...clearProps } = props; const context = useFileUploadContext(CLEAR_NAME); const store = useStoreContext(CLEAR_NAME); const fileCount = useStore((state) => state.files.size); const isDisabled = disabled || context.disabled; const onClick = React.useCallback( (event: React.MouseEvent) => { onClickProp?.(event); if (event.defaultPrevented) return; store.dispatch({ type: 'CLEAR' }); }, [store, onClickProp], ); const shouldRender = forceMount || fileCount > 0; if (!shouldRender) return null; const ClearPrimitive = asChild ? SlotPrimitive : 'button'; return ( ); } FileUploadClear.displayName = CLEAR_NAME;