{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-file-upload",
  "title": "AI File Upload",
  "description": "Upload files for AI analysis with drag and drop support.",
  "registryDependencies": [
    "button",
    "card",
    "progress"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/ai/ai-file-upload.tsx",
      "content": "\"use client\";\n\nimport {\n  Check,\n  File,\n  FileImage,\n  FileText,\n  Loader2,\n  Upload,\n  X,\n} from \"lucide-react\";\nimport Image from \"next/image\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/registry/new-york/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/registry/new-york/ui/card\";\nimport { Progress } from \"@/registry/new-york/ui/progress\";\n\nexport interface UploadedFile {\n  id: string;\n  file: File;\n  preview?: string;\n  status: \"uploading\" | \"processing\" | \"completed\" | \"error\";\n  progress?: number;\n  error?: string;\n}\n\nexport interface AIFileUploadProps {\n  onFilesSelected?: (files: File[]) => void;\n  onFileRemove?: (fileId: string) => void;\n  uploadedFiles?: UploadedFile[];\n  acceptedTypes?: string[];\n  maxSize?: number;\n  maxFiles?: number;\n  className?: string;\n  showPreview?: boolean;\n  processingStatus?: Record<string, \"processing\" | \"completed\" | \"error\">;\n}\n\nfunction formatFileSize(bytes: number): string {\n  if (bytes === 0) return \"0 Bytes\";\n  const k = 1024;\n  const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\"];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return `${Math.round((bytes / k ** i) * 100) / 100} ${sizes[i]}`;\n}\n\nfunction getFileIcon(type: string) {\n  if (type.startsWith(\"image/\")) {\n    return FileImage;\n  }\n  if (\n    type.includes(\"text\") ||\n    type.includes(\"document\") ||\n    type.includes(\"pdf\")\n  ) {\n    return FileText;\n  }\n  return File;\n}\n\nfunction getFileTypeLabel(type: string): string {\n  if (type.startsWith(\"image/\")) {\n    return \"Image\";\n  }\n  if (type.includes(\"pdf\")) {\n    return \"PDF\";\n  }\n  if (type.includes(\"text\")) {\n    return \"Text\";\n  }\n  if (\n    type.includes(\"code\") ||\n    type.includes(\"javascript\") ||\n    type.includes(\"typescript\")\n  ) {\n    return \"Code\";\n  }\n  return \"File\";\n}\n\ninterface FileUploadErrorProps {\n  message: string;\n  onDismiss?: () => void;\n}\n\nfunction FileUploadError({ message, onDismiss }: FileUploadErrorProps) {\n  return (\n    <div\n      aria-live=\"polite\"\n      className=\"flex items-start gap-3 rounded-lg border border-destructive/50 bg-destructive/10 p-3\"\n      role=\"alert\"\n    >\n      <X\n        aria-hidden=\"true\"\n        className=\"mt-0.5 size-4 shrink-0 text-destructive\"\n      />\n      <p className=\"flex-1 text-destructive text-sm\">{message}</p>\n      {onDismiss && (\n        <Button\n          aria-label=\"Dismiss error\"\n          className=\"size-6 shrink-0\"\n          onClick={onDismiss}\n          size=\"icon-sm\"\n          type=\"button\"\n          variant=\"ghost\"\n        >\n          <X className=\"size-3\" />\n        </Button>\n      )}\n    </div>\n  );\n}\n\ninterface FileUploadDropzoneProps {\n  acceptedTypes: string[];\n  maxSize?: number;\n  maxFiles?: number;\n  isDragging: boolean;\n  onDragOver: (e: React.DragEvent) => void;\n  onDragLeave: (e: React.DragEvent) => void;\n  onDrop: (e: React.DragEvent) => void;\n  onClick: () => void;\n  onFileInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n  fileInputRef: React.RefObject<HTMLInputElement | null>;\n}\n\nfunction FileUploadDropzone({\n  acceptedTypes,\n  maxSize,\n  maxFiles,\n  isDragging,\n  onDragOver,\n  onDragLeave,\n  onDrop,\n  onClick,\n  onFileInputChange,\n  fileInputRef,\n}: FileUploadDropzoneProps) {\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault();\n        onClick();\n      }\n    },\n    [onClick]\n  );\n\n  return (\n    <label\n      aria-label=\"Upload files by clicking or dragging and dropping\"\n      className={cn(\n        \"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\",\n        isDragging\n          ? \"border-primary bg-primary/5\"\n          : \"border-muted bg-muted/30 hover:border-primary/50\"\n      )}\n      onDragLeave={onDragLeave}\n      onDragOver={onDragOver}\n      onDrop={onDrop}\n      onKeyDown={handleKeyDown}\n      role=\"button\"\n      tabIndex={0}\n    >\n      <input\n        accept={acceptedTypes.join(\",\")}\n        aria-label=\"File input\"\n        className=\"hidden\"\n        multiple={maxFiles ? maxFiles > 1 : true}\n        onChange={onFileInputChange}\n        ref={fileInputRef}\n        type=\"file\"\n      />\n      <div className=\"pointer-events-none flex size-12 items-center justify-center rounded-full bg-primary/10\">\n        <Upload aria-hidden=\"true\" className=\"size-6 text-primary\" />\n      </div>\n      <div className=\"pointer-events-none flex flex-col gap-2 text-center\">\n        <p className=\"font-medium text-sm\">\n          Drag and drop files here, or{\" \"}\n          <span className=\"text-primary underline\">click to browse</span>\n        </p>\n        <p className=\"text-muted-foreground text-xs\">\n          Accepted: {acceptedTypes.join(\", \")}\n          {maxSize && ` • Max size: ${formatFileSize(maxSize)}`}\n          {maxFiles && ` • Max files: ${maxFiles}`}\n        </p>\n      </div>\n    </label>\n  );\n}\n\ninterface FileUploadItemProps {\n  uploadedFile: UploadedFile;\n  showPreview: boolean;\n  processingStatus?: Record<string, \"processing\" | \"completed\" | \"error\">;\n  error?: string;\n  onRemove?: (fileId: string) => void;\n}\n\nfunction FileUploadItem({\n  uploadedFile,\n  showPreview,\n  processingStatus,\n  error,\n  onRemove,\n}: FileUploadItemProps) {\n  const status = processingStatus?.[uploadedFile.id] || uploadedFile.status;\n  const fileError = error || uploadedFile.error;\n  const isUploading = status === \"uploading\";\n  const isCompleted = status === \"completed\";\n  const isError = status === \"error\";\n\n  return (\n    <div\n      className=\"flex flex-col gap-2 rounded-lg border bg-card p-3\"\n      role=\"listitem\"\n    >\n      <div className=\"flex items-center gap-3\">\n        {showPreview &&\n        uploadedFile.preview &&\n        uploadedFile.file.type.startsWith(\"image/\") ? (\n          <Image\n            alt={uploadedFile.file.name}\n            className=\"size-12 shrink-0 rounded-md object-cover\"\n            height={48}\n            src={uploadedFile.preview}\n            unoptimized\n            width={48}\n          />\n        ) : (\n          <div className=\"flex size-12 shrink-0 items-center justify-center rounded-md bg-muted\">\n            {uploadedFile.file.type.startsWith(\"image/\") ? (\n              <FileImage\n                aria-hidden=\"true\"\n                className=\"size-6 text-muted-foreground\"\n              />\n            ) : uploadedFile.file.type.includes(\"text\") ||\n              uploadedFile.file.type.includes(\"document\") ||\n              uploadedFile.file.type.includes(\"pdf\") ? (\n              <FileText\n                aria-hidden=\"true\"\n                className=\"size-6 text-muted-foreground\"\n              />\n            ) : (\n              <File\n                aria-hidden=\"true\"\n                className=\"size-6 text-muted-foreground\"\n              />\n            )}\n          </div>\n        )}\n        <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n          <div className=\"flex items-center justify-between gap-2\">\n            <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n              <p className=\"wrap-break-word font-medium text-sm\">\n                {uploadedFile.file.name}\n              </p>\n              <div className=\"flex flex-wrap items-center gap-2 text-muted-foreground text-xs\">\n                <span>{getFileTypeLabel(uploadedFile.file.type)}</span>\n                <span aria-hidden=\"true\">•</span>\n                <span>{formatFileSize(uploadedFile.file.size)}</span>\n              </div>\n            </div>\n            <div className=\"flex shrink-0 items-center gap-2\">\n              {isUploading && (\n                <Loader2\n                  aria-label=\"Uploading\"\n                  className=\"size-4 animate-spin text-muted-foreground\"\n                  role=\"status\"\n                />\n              )}\n              {isCompleted && (\n                <Check\n                  aria-label=\"Upload completed\"\n                  className=\"size-4 text-green-600\"\n                />\n              )}\n              {isError && (\n                <X\n                  aria-label=\"Upload error\"\n                  className=\"size-4 text-destructive\"\n                />\n              )}\n              {onRemove && (\n                <Button\n                  aria-label={`Remove ${uploadedFile.file.name}`}\n                  className=\"min-h-[32px] min-w-[32px]\"\n                  onClick={() => onRemove(uploadedFile.id)}\n                  size=\"icon\"\n                  type=\"button\"\n                  variant=\"ghost\"\n                >\n                  <X className=\"size-4\" />\n                </Button>\n              )}\n            </div>\n          </div>\n          {uploadedFile.progress !== undefined && isUploading && (\n            <Progress\n              aria-label={`Upload progress for ${uploadedFile.file.name}: ${uploadedFile.progress}%`}\n              aria-valuemax={100}\n              aria-valuemin={0}\n              aria-valuenow={uploadedFile.progress}\n              className=\"h-1.5\"\n              role=\"progressbar\"\n              value={uploadedFile.progress}\n            />\n          )}\n          {fileError && (\n            <p className=\"text-destructive text-xs\" role=\"alert\">\n              {fileError}\n            </p>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\ninterface FileUploadEmptyStateProps {\n  maxFiles: number;\n}\n\nfunction FileUploadEmptyState({ maxFiles }: FileUploadEmptyStateProps) {\n  return (\n    <div\n      className=\"flex flex-col items-center justify-center gap-4 py-12 text-center\"\n      role=\"status\"\n    >\n      <div className=\"flex size-12 items-center justify-center rounded-full bg-muted\">\n        <File aria-hidden=\"true\" className=\"size-6 text-muted-foreground\" />\n      </div>\n      <p className=\"text-muted-foreground text-sm\">\n        Maximum files reached ({maxFiles})\n      </p>\n    </div>\n  );\n}\n\nexport default function AIFileUpload({\n  onFilesSelected,\n  onFileRemove,\n  uploadedFiles = [],\n  acceptedTypes = [\"image/*\", \"application/pdf\", \"text/*\"],\n  maxSize = 10 * 1024 * 1024,\n  maxFiles = 10,\n  className,\n  showPreview = true,\n  processingStatus,\n}: AIFileUploadProps) {\n  const [isDragging, setIsDragging] = useState(false);\n  const [errors, setErrors] = useState<Record<string, string>>({});\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const errorTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);\n\n  const validateFile = useCallback(\n    (file: File): string | null => {\n      if (maxSize && file.size > maxSize) {\n        return `File size exceeds ${formatFileSize(maxSize)}`;\n      }\n\n      if (acceptedTypes.length > 0) {\n        const isAccepted = acceptedTypes.some((type) => {\n          if (type.endsWith(\"/*\")) {\n            return file.type.startsWith(type.slice(0, -2));\n          }\n          return file.type === type;\n        });\n\n        if (!isAccepted) {\n          return `File type not supported. Accepted types: ${acceptedTypes.join(\", \")}`;\n        }\n      }\n\n      return null;\n    },\n    [maxSize, acceptedTypes]\n  );\n\n  const handleFiles = useCallback(\n    (files: FileList | null) => {\n      if (!files || files.length === 0) return;\n\n      const fileArray = Array.from(files);\n      const validFiles: File[] = [];\n      const newErrors: Record<string, string> = {};\n\n      fileArray.forEach((file) => {\n        const error = validateFile(file);\n        if (error) {\n          newErrors[file.name] = error;\n        } else {\n          validFiles.push(file);\n        }\n      });\n\n      if (Object.keys(newErrors).length > 0) {\n        setErrors(newErrors);\n        if (errorTimeoutRef.current) {\n          clearTimeout(errorTimeoutRef.current);\n        }\n        errorTimeoutRef.current = setTimeout(() => {\n          setErrors((prev) => {\n            const next = { ...prev };\n            delete next._general;\n            return next;\n          });\n        }, 5000);\n      }\n\n      if (validFiles.length > 0) {\n        const remainingSlots = maxFiles - uploadedFiles.length;\n        const filesToAdd = validFiles.slice(0, remainingSlots);\n\n        if (filesToAdd.length < validFiles.length) {\n          const skipped = validFiles.length - filesToAdd.length;\n          newErrors[\"_general\"] =\n            `${skipped} file${skipped !== 1 ? \"s\" : \"\"} skipped. Maximum ${maxFiles} files allowed.`;\n          setErrors(newErrors);\n        }\n\n        onFilesSelected?.(filesToAdd);\n      }\n\n      if (fileInputRef.current) {\n        fileInputRef.current.value = \"\";\n      }\n    },\n    [maxFiles, uploadedFiles.length, onFilesSelected, validateFile]\n  );\n\n  const handleDragOver = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setIsDragging(true);\n  }, []);\n\n  const handleDragLeave = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    const relatedTarget = e.relatedTarget as HTMLElement;\n    if (!e.currentTarget.contains(relatedTarget)) {\n      setIsDragging(false);\n    }\n  }, []);\n\n  const handleDrop = useCallback(\n    (e: React.DragEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      setIsDragging(false);\n\n      if (uploadedFiles.length >= maxFiles) {\n        setErrors({\n          _general: `Maximum ${maxFiles} files allowed`,\n        });\n        return;\n      }\n\n      handleFiles(e.dataTransfer.files);\n    },\n    [handleFiles, maxFiles, uploadedFiles.length]\n  );\n\n  const handleFileInputChange = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      handleFiles(e.target.files);\n    },\n    [handleFiles]\n  );\n\n  const handleClick = useCallback(() => {\n    fileInputRef.current?.click();\n  }, []);\n\n  const handleDismissError = useCallback(() => {\n    setErrors((prev) => {\n      const next = { ...prev };\n      delete next._general;\n      return next;\n    });\n  }, []);\n\n  useEffect(\n    () => () => {\n      if (errorTimeoutRef.current) {\n        clearTimeout(errorTimeoutRef.current);\n      }\n    },\n    []\n  );\n\n  const canAddMore = uploadedFiles.length < maxFiles;\n\n  return (\n    <Card className={cn(\"w-full shadow-xs\", className)}>\n      <CardHeader>\n        <div className=\"flex flex-col gap-2\">\n          <CardTitle>Upload Files</CardTitle>\n          <CardDescription>\n            Upload images, documents, or code files for AI analysis\n          </CardDescription>\n        </div>\n      </CardHeader>\n      <CardContent className=\"-mt-4\">\n        <div className=\"flex flex-col gap-4\">\n          {canAddMore && (\n            <FileUploadDropzone\n              acceptedTypes={acceptedTypes}\n              fileInputRef={fileInputRef}\n              isDragging={isDragging}\n              maxFiles={maxFiles}\n              maxSize={maxSize}\n              onClick={handleClick}\n              onDragLeave={handleDragLeave}\n              onDragOver={handleDragOver}\n              onDrop={handleDrop}\n              onFileInputChange={handleFileInputChange}\n            />\n          )}\n\n          {errors._general && (\n            <FileUploadError\n              message={errors._general}\n              onDismiss={handleDismissError}\n            />\n          )}\n\n          {uploadedFiles.length > 0 && (\n            <div className=\"flex flex-col gap-3\">\n              <div className=\"flex items-center justify-between\">\n                <h3 className=\"font-medium text-sm\">\n                  Uploaded Files ({uploadedFiles.length}\n                  {maxFiles && ` / ${maxFiles}`})\n                </h3>\n                {canAddMore && (\n                  <Button\n                    aria-label=\"Add more files\"\n                    className=\"min-h-[32px]\"\n                    onClick={handleClick}\n                    size=\"sm\"\n                    type=\"button\"\n                    variant=\"outline\"\n                  >\n                    <Upload aria-hidden=\"true\" className=\"size-4\" />\n                    Add More\n                  </Button>\n                )}\n              </div>\n              <div className=\"flex flex-col gap-2\" role=\"list\">\n                {uploadedFiles.map((uploadedFile) => (\n                  <FileUploadItem\n                    error={errors[uploadedFile.file.name]}\n                    key={uploadedFile.id}\n                    onRemove={onFileRemove}\n                    processingStatus={processingStatus}\n                    showPreview={showPreview}\n                    uploadedFile={uploadedFile}\n                  />\n                ))}\n              </div>\n            </div>\n          )}\n\n          {uploadedFiles.length === 0 && !canAddMore && (\n            <FileUploadEmptyState maxFiles={maxFiles} />\n          )}\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "block",
    "ai"
  ],
  "type": "registry:ui"
}