Todos os componentes

AI File Upload

AI File Upload é um componente de formulário para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.

HextaUI MIT forms

O que é

AI File Upload é um componente de formulário para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Não precisa de pacotes além do que o projeto já tem

Licença

Este componente vem de HextaUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

AI File Upload

registry/new-york/blocks/ai/ai-file-upload.tsx
"use client";

import {
  Check,
  File,
  FileImage,
  FileText,
  Loader2,
  Upload,
  X,
} from "lucide-react";
import Image from "next/image";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/new-york/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/registry/new-york/ui/card";
import { Progress } from "@/registry/new-york/ui/progress";

export interface UploadedFile {
  id: string;
  file: File;
  preview?: string;
  status: "uploading" | "processing" | "completed" | "error";
  progress?: number;
  error?: string;
}

export interface AIFileUploadProps {
  onFilesSelected?: (files: File[]) => void;
  onFileRemove?: (fileId: string) => void;
  uploadedFiles?: UploadedFile[];
  acceptedTypes?: string[];
  maxSize?: number;
  maxFiles?: number;
  className?: string;
  showPreview?: boolean;
  processingStatus?: Record<string, "processing" | "completed" | "error">;
}

function formatFileSize(bytes: number): string {
  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 `${Math.round((bytes / k ** i) * 100) / 100} ${sizes[i]}`;
}

function getFileIcon(type: string) {
  if (type.startsWith("image/")) {
    return FileImage;
  }
  if (
    type.includes("text") ||
    type.includes("document") ||
    type.includes("pdf")
  ) {
    return FileText;
  }
  return File;
}

function getFileTypeLabel(type: string): string {
  if (type.startsWith("image/")) {
    return "Image";
  }
  if (type.includes("pdf")) {
    return "PDF";
  }
  if (type.includes("text")) {
    return "Text";
  }
  if (
    type.includes("code") ||
    type.includes("javascript") ||
    type.includes("typescript")
  ) {
    return "Code";
  }
  return "File";
}

interface FileUploadErrorProps {
  message: string;
  onDismiss?: () => void;
}

function FileUploadError({ message, onDismiss }: FileUploadErrorProps) {
  return (
    <div
      aria-live="polite"
      className="flex items-start gap-3 rounded-lg border border-destructive/50 bg-destructive/10 p-3"
      role="alert"
    >
      <X
        aria-hidden="true"
        className="mt-0.5 size-4 shrink-0 text-destructive"
      />
      <p className="flex-1 text-destructive text-sm">{message}</p>
      {onDismiss && (
        <Button
          aria-label="Dismiss error"
          className="size-6 shrink-0"
          onClick={onDismiss}
          size="icon-sm"
          type="button"
          variant="ghost"
        >
          <X className="size-3" />
        </Button>
      )}
    </div>
  );
}

interface FileUploadDropzoneProps {
  acceptedTypes: string[];
  maxSize?: number;
  maxFiles?: number;
  isDragging: boolean;
  onDragOver: (e: React.DragEvent) => void;
  onDragLeave: (e: React.DragEvent) => void;
  onDrop: (e: React.DragEvent) => void;
  onClick: () => void;
  onFileInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  fileInputRef: React.RefObject<HTMLInputElement | null>;
}

function FileUploadDropzone({
  acceptedTypes,
  maxSize,
  maxFiles,
  isDragging,
  onDragOver,
  onDragLeave,
  onDrop,
  onClick,
  onFileInputChange,
  fileInputRef,
}: FileUploadDropzoneProps) {
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === "Enter" || e.key === " ") {
        e.preventDefault();
        onClick();
      }
    },
    [onClick]
  );

  return (
    <label
      aria-label="Upload files by clicking or dragging and dropping"
      className={cn(
        "relative flex min-h-[32px] min-w-[32px] cursor-pointer flex-col items-center justify-center gap-4 rounded-lg border-2 border-dashed p-8 transition-colors focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
        isDragging
          ? "border-primary bg-primary/5"
          : "border-muted bg-muted/30 hover:border-primary/50"
      )}
      onDragLeave={onDragLeave}
      onDragOver={onDragOver}
      onDrop={onDrop}
      onKeyDown={handleKeyDown}
      role="button"
      tabIndex={0}
    >
      <input
        accept={acceptedTypes.join(",")}
        aria-label="File input"
        className="hidden"
        multiple={maxFiles ? maxFiles > 1 : true}
        onChange={onFileInputChange}
        ref={fileInputRef}
        type="file"
      />
      <div className="pointer-events-none flex size-12 items-center justify-center rounded-full bg-primary/10">
        <Upload aria-hidden="true" className="size-6 text-primary" />
      </div>
      <div className="pointer-events-none flex flex-col gap-2 text-center">
        <p className="font-medium text-sm">
          Drag and drop files here, or{" "}
          <span className="text-primary underline">click to browse</span>
        </p>
        <p className="text-muted-foreground text-xs">
          Accepted: {acceptedTypes.join(", ")}
          {maxSize && ` • Max size: ${formatFileSize(maxSize)}`}
          {maxFiles && ` • Max files: ${maxFiles}`}
        </p>
      </div>
    </label>
  );
}

interface FileUploadItemProps {
  uploadedFile: UploadedFile;
  showPreview: boolean;
  processingStatus?: Record<string, "processing" | "completed" | "error">;
  error?: string;
  onRemove?: (fileId: string) => void;
}

function FileUploadItem({
  uploadedFile,
  showPreview,
  processingStatus,
  error,
  onRemove,
}: FileUploadItemProps) {
  const status = processingStatus?.[uploadedFile.id] || uploadedFile.status;
  const fileError = error || uploadedFile.error;
  const isUploading = status === "uploading";
  const isCompleted = status === "completed";
  const isError = status === "error";

  return (
    <div
      className="flex flex-col gap-2 rounded-lg border bg-card p-3"
      role="listitem"
    >
      <div className="flex items-center gap-3">
        {showPreview &&
        uploadedFile.preview &&
        uploadedFile.file.type.startsWith("image/") ? (
          <Image
            alt={uploadedFile.file.name}
            className="size-12 shrink-0 rounded-md object-cover"
            height={48}
            src={uploadedFile.preview}
            unoptimized
            width={48}
          />
        ) : (
          <div className="flex size-12 shrink-0 items-center justify-center rounded-md bg-muted">
            {uploadedFile.file.type.startsWith("image/") ? (
              <FileImage
                aria-hidden="true"
                className="size-6 text-muted-foreground"
              />
            ) : uploadedFile.file.type.includes("text") ||
              uploadedFile.file.type.includes("document") ||
              uploadedFile.file.type.includes("pdf") ? (
              <FileText
                aria-hidden="true"
                className="size-6 text-muted-foreground"
              />
            ) : (
              <File
                aria-hidden="true"
                className="size-6 text-muted-foreground"
              />
            )}
          </div>
        )}
        <div className="flex min-w-0 flex-1 flex-col gap-2">
          <div className="flex items-center justify-between gap-2">
            <div className="flex min-w-0 flex-1 flex-col gap-2">
              <p className="wrap-break-word font-medium text-sm">
                {uploadedFile.file.name}
              </p>
              <div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
                <span>{getFileTypeLabel(uploadedFile.file.type)}</span>
                <span aria-hidden="true">•</span>
                <span>{formatFileSize(uploadedFile.file.size)}</span>
              </div>
            </div>
            <div className="flex shrink-0 items-center gap-2">
              {isUploading && (
                <Loader2
                  aria-label="Uploading"
                  className="size-4 animate-spin text-muted-foreground"
                  role="status"
                />
              )}
              {isCompleted && (
                <Check
                  aria-label="Upload completed"
                  className="size-4 text-green-600"
                />
              )}
              {isError && (
                <X
                  aria-label="Upload error"
                  className="size-4 text-destructive"
                />
              )}
              {onRemove && (
                <Button
                  aria-label={`Remove ${uploadedFile.file.name}`}
                  className="min-h-[32px] min-w-[32px]"
                  onClick={() => onRemove(uploadedFile.id)}
                  size="icon"
                  type="button"
                  variant="ghost"
                >
                  <X className="size-4" />
                </Button>
              )}
            </div>
          </div>
          {uploadedFile.progress !== undefined && isUploading && (
            <Progress
              aria-label={`Upload progress for ${uploadedFile.file.name}: ${uploadedFile.progress}%`}
              aria-valuemax={100}
              aria-valuemin={0}
              aria-valuenow={uploadedFile.progress}
              className="h-1.5"
              role="progressbar"
              value={uploadedFile.progress}
            />
          )}
          {fileError && (
            <p className="text-destructive text-xs" role="alert">
              {fileError}
            </p>
          )}
        </div>
      </div>
    </div>
  );
}

interface FileUploadEmptyStateProps {
  maxFiles: number;
}

function FileUploadEmptyState({ maxFiles }: FileUploadEmptyStateProps) {
  return (
    <div
      className="flex flex-col items-center justify-center gap-4 py-12 text-center"
      role="status"
    >
      <div className="flex size-12 items-center justify-center rounded-full bg-muted">
        <File aria-hidden="true" className="size-6 text-muted-foreground" />
      </div>
      <p className="text-muted-foreground text-sm">
        Maximum files reached ({maxFiles})
      </p>
    </div>
  );
}

export default function AIFileUpload({
  onFilesSelected,
  onFileRemove,
  uploadedFiles = [],
  acceptedTypes = ["image/*", "application/pdf", "text/*"],
  maxSize = 10 * 1024 * 1024,
  maxFiles = 10,
  className,
  showPreview = true,
  processingStatus,
}: AIFileUploadProps) {
  const [isDragging, setIsDragging] = useState(false);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const fileInputRef = useRef<HTMLInputElement>(null);
  const errorTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);

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

      if (acceptedTypes.length > 0) {
        const isAccepted = acceptedTypes.some((type) => {
          if (type.endsWith("/*")) {
            return file.type.startsWith(type.slice(0, -2));
          }
          return file.type === type;
        });

        if (!isAccepted) {
          return `File type not supported. Accepted types: ${acceptedTypes.join(", ")}`;
        }
      }

      return null;
    },
    [maxSize, acceptedTypes]
  );

  const handleFiles = useCallback(
    (files: FileList | null) => {
      if (!files || files.length === 0) return;

      const fileArray = Array.from(files);
      const validFiles: File[] = [];
      const newErrors: Record<string, string> = {};

      fileArray.forEach((file) => {
        const error = validateFile(file);
        if (error) {
          newErrors[file.name] = error;
        } else {
          validFiles.push(file);
        }
      });

      if (Object.keys(newErrors).length > 0) {
        setErrors(newErrors);
        if (errorTimeoutRef.current) {
          clearTimeout(errorTimeoutRef.current);
        }
        errorTimeoutRef.current = setTimeout(() => {
          setErrors((prev) => {
            const next = { ...prev };
            delete next._general;
            return next;
          });
        }, 5000);
      }

      if (validFiles.length > 0) {
        const remainingSlots = maxFiles - uploadedFiles.length;
        const filesToAdd = validFiles.slice(0, remainingSlots);

        if (filesToAdd.length < validFiles.length) {
          const skipped = validFiles.length - filesToAdd.length;
          newErrors["_general"] =
            `${skipped} file${skipped !== 1 ? "s" : ""} skipped. Maximum ${maxFiles} files allowed.`;
          setErrors(newErrors);
        }

        onFilesSelected?.(filesToAdd);
      }

      if (fileInputRef.current) {
        fileInputRef.current.value = "";
      }
    },
    [maxFiles, uploadedFiles.length, onFilesSelected, validateFile]
  );

  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(true);
  }, []);

  const handleDragLeave = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    const relatedTarget = e.relatedTarget as HTMLElement;
    if (!e.currentTarget.contains(relatedTarget)) {
      setIsDragging(false);
    }
  }, []);

  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      e.stopPropagation();
      setIsDragging(false);

      if (uploadedFiles.length >= maxFiles) {
        setErrors({
          _general: `Maximum ${maxFiles} files allowed`,
        });
        return;
      }

      handleFiles(e.dataTransfer.files);
    },
    [handleFiles, maxFiles, uploadedFiles.length]
  );

  const handleFileInputChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      handleFiles(e.target.files);
    },
    [handleFiles]
  );

  const handleClick = useCallback(() => {
    fileInputRef.current?.click();
  }, []);

  const handleDismissError = useCallback(() => {
    setErrors((prev) => {
      const next = { ...prev };
      delete next._general;
      return next;
    });
  }, []);

  useEffect(
    () => () => {
      if (errorTimeoutRef.current) {
        clearTimeout(errorTimeoutRef.current);
      }
    },
    []
  );

  const canAddMore = uploadedFiles.length < maxFiles;

  return (
    <Card className={cn("w-full shadow-xs", className)}>
      <CardHeader>
        <div className="flex flex-col gap-2">
          <CardTitle>Upload Files</CardTitle>
          <CardDescription>
            Upload images, documents, or code files for AI analysis
          </CardDescription>
        </div>
      </CardHeader>
      <CardContent className="-mt-4">
        <div className="flex flex-col gap-4">
          {canAddMore && (
            <FileUploadDropzone
              acceptedTypes={acceptedTypes}
              fileInputRef={fileInputRef}
              isDragging={isDragging}
              maxFiles={maxFiles}
              maxSize={maxSize}
              onClick={handleClick}
              onDragLeave={handleDragLeave}
              onDragOver={handleDragOver}
              onDrop={handleDrop}
              onFileInputChange={handleFileInputChange}
            />
          )}

          {errors._general && (
            <FileUploadError
              message={errors._general}
              onDismiss={handleDismissError}
            />
          )}

          {uploadedFiles.length > 0 && (
            <div className="flex flex-col gap-3">
              <div className="flex items-center justify-between">
                <h3 className="font-medium text-sm">
                  Uploaded Files ({uploadedFiles.length}
                  {maxFiles && ` / ${maxFiles}`})
                </h3>
                {canAddMore && (
                  <Button
                    aria-label="Add more files"
                    className="min-h-[32px]"
                    onClick={handleClick}
                    size="sm"
                    type="button"
                    variant="outline"
                  >
                    <Upload aria-hidden="true" className="size-4" />
                    Add More
                  </Button>
                )}
              </div>
              <div className="flex flex-col gap-2" role="list">
                {uploadedFiles.map((uploadedFile) => (
                  <FileUploadItem
                    error={errors[uploadedFile.file.name]}
                    key={uploadedFile.id}
                    onRemove={onFileRemove}
                    processingStatus={processingStatus}
                    showPreview={showPreview}
                    uploadedFile={uploadedFile}
                  />
                ))}
              </div>
            </div>
          )}

          {uploadedFiles.length === 0 && !canAddMore && (
            <FileUploadEmptyState maxFiles={maxFiles} />
          )}
        </div>
      </CardContent>
    </Card>
  );
}

Componentes parecidos