File Uploader

PreviousNext

A file uploader component for react native

Docs
rigiduicomponent

Preview

Loading preview…
r/new-york/file-uploader/file-uploader-rn.tsx
import React, { useState, useCallback, useEffect, createContext, useContext } from 'react';
import { View, Text, Alert } from 'react-native';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import * as DocumentPicker from 'expo-document-picker';
import { AntDesign } from "@react-native-vector-icons/ant-design";

export interface FileWithPreview {
  id: string;
  file: DocumentPicker.DocumentPickerAsset;
  name: string;
  size: number;
  type: string;
  progress: number;
  status: 'uploading' | 'complete' | 'error';
  error?: string | null;
  preview?: string | null;
}

interface FileUploaderContextType {
  files: FileWithPreview[];
  maxFiles: number;
  maxSize: number;
  accept: string[];
  onFilesReady?: (files: DocumentPicker.DocumentPickerAsset[]) => void;
  addFiles: (files: DocumentPicker.DocumentPickerAsset[]) => void;
  removeFile: (fileId: string) => void;
  clearAllFiles: () => void;
  updateFile: (fileId: string, updates: Partial<FileWithPreview>) => void;
  formatFileSize: (bytes: number) => string;
  validateFile: (file: DocumentPicker.DocumentPickerAsset) => string | null;
  getFileIcon: (file: DocumentPicker.DocumentPickerAsset) => React.ReactNode;
}

const FileUploaderContext = createContext<FileUploaderContextType | null>(null);

const useFileUploader = () => {
  const context = useContext(FileUploaderContext);
  if (!context) {
    throw new Error('FileUploader components must be used within a FileUploader');
  }
  return context;
};

const FileUploaderProgress = React.forwardRef<
  View,
  React.ComponentProps<typeof View> & { value?: number }
>(({ className, value = 0, ...props }, ref) => (
  <View
    ref={ref}
    className={cn("relative w-full overflow-hidden rounded-full bg-secondary h-1", className)}
    {...props}
  >
    <View
      className="h-full bg-primary transition-all duration-300 ease-in-out"
      style={{ width: `${value}%` }}
    />
  </View>
));
FileUploaderProgress.displayName = "FileUploaderProgress";

interface FileUploaderPreviewProps {
  file: FileWithPreview;
  className?: string;
}

function FileUploaderPreview({ file, className }: FileUploaderPreviewProps) {
  const { getFileIcon } = useFileUploader();

  return (
    <View className={cn("w-12 h-12 bg-muted rounded-md flex items-center justify-center shrink-0 border", className)}>
      {file.error ? (
        <AntDesign name="exclamation-circle" size={24} />
      ) : (
        getFileIcon(file.file)
      )}
    </View>
  );
}

interface FileUploaderDropZoneProps {
  className?: string;
  disabled?: boolean;
}

function FileUploaderDropZone({ className, disabled }: FileUploaderDropZoneProps) {
  const {
    files,
    maxFiles,
    maxSize,
    accept,
    addFiles,
    formatFileSize
  } = useFileUploader();

  const handlePickDocument = async () => {
    try {
      const result = await DocumentPicker.getDocumentAsync({
        type: accept.length > 0 ? accept : '*/*',
        multiple: true,
        copyToCacheDirectory: true,
      });

      if (!result.canceled) {
        addFiles(result.assets);
      }
    } catch (error) {
      Alert.alert('Error', 'Failed to pick files');
    }
  };

  const getReadableFileTypes = useCallback(() => {
    return accept.map(type => {
      if (type === 'image/*') return 'Images';
      if (type === 'application/pdf') return 'PDF';
      if (type === 'text/*') return 'Text files';
      if (type === 'video/*') return 'Videos';
      if (type === 'audio/*') return 'Audio';
      return type;
    });
  }, [accept]);

  const isDisabled = disabled || files.length >= maxFiles;

  return (
    <Card
      className={cn(
        "relative border-2 border-dashed border-muted",
        isDisabled && "opacity-50",
        className
      )}
    >
      <CardContent className="flex flex-col items-center justify-center p-8">
        <View className="flex items-center justify-center w-16 h-16 rounded-full mb-4 bg-muted/50">
          <AntDesign name='cloud-upload' size={32} />
        </View>

        <Text className="text-lg font-semibold mb-2 text-foreground">
          Upload Files
        </Text>

        <Text className="text-sm text-muted-foreground mb-4 text-center">
          Tap to browse and select files
        </Text>

        <View className="flex flex-row flex-wrap gap-2 mb-4 justify-center">
          {getReadableFileTypes().map((type, index) => (
            <Badge key={index} variant="secondary">
              <Text className='text-xs'>
                {type}
              </Text>
            </Badge>
          ))}
        </View>

        <Button
          onPress={handlePickDocument}
          variant="outline"
          disabled={isDisabled}
        >
          <Text>
            Choose Files
          </Text>
        </Button>

        <Text className="text-xs text-muted-foreground mt-2 text-center">
          Max {maxFiles} files, up to {formatFileSize(maxSize)} each
        </Text>
      </CardContent>
    </Card>
  );
}

interface FileItemProps {
  file: FileWithPreview;
}

function FileItem({ file }: FileItemProps) {
  const { removeFile, formatFileSize } = useFileUploader();

  return (
    <Card className="relative overflow-hidden">
      <View className={cn(
        "absolute inset-0 opacity-0",
        file.error ? "bg-destructive/5" : "bg-primary/5"
      )} />
      <CardContent className="p-4 relative">
        <View className="flex flex-row items-center gap-3">
          <FileUploaderPreview file={file} />

          <View className="flex-1 min-w-0">
            <View className="flex flex-row items-center gap-2 mb-1">
              <Text className="text-sm font-medium text-foreground flex-1" numberOfLines={1}>
                {file.name}
              </Text>
              {file.status === 'complete' && !file.error && (
                <AntDesign name='check' size={16} />
              )}
            </View>

            <Text className="text-xs text-muted-foreground mb-2">
              {formatFileSize(file.size)} • {file.type}
            </Text>

            {file.error ? (
              <View className="flex flex-row items-center gap-1">
                <AntDesign name="exclamation-circle" size={12} />
                <Text className="text-xs text-destructive">{file.error}</Text>
              </View>
            ) : (
              <FileUploaderProgress
                value={file.progress}
                className="h-1 rounded-full bg-secondary"
              />
            )}
          </View>

          <Button
            variant="ghost"
            size="icon"
            onPress={() => removeFile(file.id)}
            className="shrink-0 h-8 w-8 rounded-full opacity-70"
          >
            <AntDesign name='close' size={16} />
          </Button>
        </View>
      </CardContent>
    </Card>
  );
}

interface FileUploaderFileListProps {
  className?: string;
  showHeader?: boolean;
}

function FileUploaderFileList({
  className,
  showHeader = true,
}: FileUploaderFileListProps) {
  const {
    files,
    maxFiles,
    clearAllFiles
  } = useFileUploader();

  if (files.length === 0) {
    return null;
  }

  return (
    <View className={cn("gap-3", className)}>
      {showHeader && (
        <View className="flex flex-row items-center justify-between">
          <Text className="text-sm font-medium text-foreground">
            Uploaded Files ({files.length}/{maxFiles})
          </Text>
          <Button
            variant="ghost"
            size="sm"
            onPress={clearAllFiles}
            className="text-xs"
          >
            <Text>
              Clear All
            </Text>
          </Button>
        </View>
      )}

      {files.map((fileData) => (
        <FileItem
          key={fileData.id}
          file={fileData}
        />
      ))}
    </View>
  );
}

export interface FileUploaderProps {
  onFilesReady?: (files: DocumentPicker.DocumentPickerAsset[]) => void;
  maxFiles?: number;
  maxSize?: number;
  accept?: string[];
  className?: string;
  children?: React.ReactNode;
}

export function FileUploader({
  onFilesReady,
  maxFiles = 10,
  maxSize = 10 * 1024 * 1024,
  accept = ['image/*', 'application/pdf', 'text/*'],
  className,
  children
}: FileUploaderProps) {
  const [files, setFiles] = useState<FileWithPreview[]>([]);

  useEffect(() => {
    return () => {
      files.forEach(file => {
        if (file.preview) {
        }
      });
    };
  }, [files]);

  const getFileIcon = useCallback((file: DocumentPicker.DocumentPickerAsset) => {
    if (file.mimeType?.startsWith('image/')) return <AntDesign name="file-image" size={24} />;
    return <AntDesign name="folder-open" size={24} />;
  }, []);

  const formatFileSize = useCallback((bytes: number) => {
    if (bytes === 0) return '0 Bytes';
    const k = 1024;
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  }, []);

  const validateFile = useCallback((file: DocumentPicker.DocumentPickerAsset) => {
    if (file.size && file.size > maxSize) {
      return `File size exceeds ${formatFileSize(maxSize)}`;
    }

    const fileType = file.mimeType || '';
    const isAccepted = accept.some(type => {
      if (type.endsWith('/*')) {
        return fileType.startsWith(type.slice(0, -1));
      }
      return fileType === type;
    });

    if (!isAccepted && accept.length > 0) {
      return 'File type not supported';
    }

    return null;
  }, [maxSize, accept, formatFileSize]);

  const addFiles = useCallback((newFiles: DocumentPicker.DocumentPickerAsset[]) => {
    if (files.length >= maxFiles) return;

    const filesToAdd = newFiles.slice(0, maxFiles - files.length);

    const processedFiles = filesToAdd.map(file => {
      const error = validateFile(file);

      return {
        id: Math.random().toString(36).substring(2, 11),
        file,
        name: file.name,
        size: file.size || 0,
        type: file.mimeType || 'unknown',
        progress: error ? 0 : 100,
        status: error ? 'error' : 'complete',
        error,
        preview: file.mimeType?.startsWith('image/') ? file.uri : null
      } as FileWithPreview;
    });

    const newFileList = [...files, ...processedFiles];
    setFiles(newFileList);

    const validFiles = newFileList.filter(f => !f.error).map(f => f.file);
    if (onFilesReady) {
      onFilesReady(validFiles);
    }
  }, [files, maxFiles, validateFile, onFilesReady]);

  const removeFile = useCallback((fileId: string) => {
    setFiles(prevFiles => {
      const updatedFiles = prevFiles.filter(f => f.id !== fileId);

      if (onFilesReady) {
        const validFiles = updatedFiles.filter(f => !f.error).map(f => f.file);
        onFilesReady(validFiles);
      }

      return updatedFiles;
    });
  }, [onFilesReady]);

  const clearAllFiles = useCallback(() => {
    setFiles([]);
    if (onFilesReady) {
      onFilesReady([]);
    }
  }, [onFilesReady]);

  const updateFile = useCallback((fileId: string, updates: Partial<FileWithPreview>) => {
    setFiles(prevFiles => {
      const updatedFiles = prevFiles.map(f =>
        f.id === fileId ? { ...f, ...updates } : f
      );

      if (onFilesReady) {
        const validFiles = updatedFiles.filter(f => !f.error).map(f => f.file);
        onFilesReady(validFiles);
      }

      return updatedFiles;
    });
  }, [onFilesReady]);

  const contextValue: FileUploaderContextType = {
    files,
    maxFiles,
    maxSize,
    accept,
    onFilesReady,
    addFiles,
    removeFile,
    clearAllFiles,
    updateFile,
    formatFileSize,
    validateFile,
    getFileIcon,
  };

  return (
    <FileUploaderContext.Provider value={contextValue}>
      <View className={cn("w-full gap-4", className)}>
        {children}
      </View>
    </FileUploaderContext.Provider>
  );
}

FileUploader.DropZone = FileUploaderDropZone;
FileUploader.FileList = FileUploaderFileList;
FileUploader.Progress = FileUploaderProgress;
FileUploader.Preview = FileUploaderPreview;

export { FileUploaderDropZone, FileUploaderFileList, FileUploaderProgress, FileUploaderPreview };

Installation

npx shadcn@latest add @rigidui/file-uploader-rn

Usage

import { FileUploaderRn } from "@/components/file-uploader-rn"
<FileUploaderRn />