-

File Upload

PreviousNext

A compact file upload surface for ZGI agents, workflow inputs, datasets, and knowledge resources.

Installation

pnpm
pnpm dlx shadcn@latest add https://ui.zgi.ai/r/file-upload.json
npm
pnpm dlx shadcn@latest add https://ui.zgi.ai/r/file-upload.json
yarn
yarn dlx shadcn@latest add https://ui.zgi.ai/r/file-upload.json

Component

components/ui/file-upload.tsx
"use client"

import * as React from "react"
import {
  CheckCircle2Icon,
  Loader2Icon,
  Trash2Icon,
  UploadCloudIcon,
  XCircleIcon,
} from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { FileIcon } from "@/components/ui/file-icon"
import { Progress } from "@/components/ui/progress"

export type FileUploadItemStatus = "pending" | "uploading" | "success" | "error"

export interface FileUploadItem {
  id: string
  file: File
  progress?: number
  status?: FileUploadItemStatus
  error?: string
}

export interface FileUploadProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "title"> {
  value?: FileUploadItem[]
  onValueChange?: (items: FileUploadItem[]) => void
  onFilesAdded?: (files: File[]) => void
  accept?: string
  maxFiles?: number
  maxSizeMB?: number
  disabled?: boolean
  multiple?: boolean
  title?: React.ReactNode
  description?: React.ReactNode
  browseText?: string
  emptyText?: string
}

function formatBytes(bytes: number) {
  if (bytes === 0) return "0 B"
  const units = ["B", "KB", "MB", "GB"]
  const index = Math.min(
    Math.floor(Math.log(bytes) / Math.log(1024)),
    units.length - 1
  )
  return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`
}

function buildUploadItem(file: File): FileUploadItem {
  return {
    id: `${file.name}-${file.size}-${file.lastModified}`,
    file,
    progress: 0,
    status: "pending",
  }
}

export function FileUpload({
  value,
  onValueChange,
  onFilesAdded,
  accept,
  maxFiles = 5,
  maxSizeMB = 25,
  disabled = false,
  multiple = true,
  title = "Drop files here",
  description = "Attach files for a ZGI agent, workflow, or dataset.",
  browseText = "Browse files",
  emptyText = "No files selected",
  className,
  ...props
}: FileUploadProps) {
  const inputRef = React.useRef<HTMLInputElement>(null)
  const [internalItems, setInternalItems] = React.useState<FileUploadItem[]>([])
  const [isDragging, setIsDragging] = React.useState(false)
  const items = value ?? internalItems
  const setItems = onValueChange ?? setInternalItems
  const maxBytes = maxSizeMB * 1024 * 1024

  function addFiles(fileList: FileList | File[]) {
    if (disabled) return

    const incoming = Array.from(fileList)
    const existingIds = new Set(items.map((item) => item.id))
    const nextItems = [...items]

    for (const file of incoming) {
      if (nextItems.length >= maxFiles) break

      const item = buildUploadItem(file)
      if (existingIds.has(item.id)) continue

      if (file.size > maxBytes) {
        nextItems.push({
          ...item,
          progress: 100,
          status: "error",
          error: `File is larger than ${maxSizeMB} MB`,
        })
      } else {
        nextItems.push(item)
      }
    }

    setItems(nextItems)
    onFilesAdded?.(incoming)
  }

  function removeItem(id: string) {
    setItems(items.filter((item) => item.id !== id))
  }

  return (
    <div className={cn("space-y-3", className)} {...props}>
      <div
        role="button"
        tabIndex={disabled ? -1 : 0}
        aria-disabled={disabled}
        onClick={() => inputRef.current?.click()}
        onKeyDown={(event) => {
          if (event.key === "Enter" || event.key === " ") {
            event.preventDefault()
            inputRef.current?.click()
          }
        }}
        onDragOver={(event) => {
          event.preventDefault()
          setIsDragging(true)
        }}
        onDragLeave={() => setIsDragging(false)}
        onDrop={(event) => {
          event.preventDefault()
          setIsDragging(false)
          addFiles(event.dataTransfer.files)
        }}
        className={cn(
          "border-border bg-card text-card-foreground flex min-h-36 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed p-5 text-center transition-colors",
          "hover:border-primary/60 hover:bg-accent/30",
          "focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
          isDragging && "border-primary bg-primary/5",
          disabled && "pointer-events-none opacity-50"
        )}
      >
        <input
          ref={inputRef}
          type="file"
          accept={accept}
          multiple={multiple}
          disabled={disabled}
          className="sr-only"
          onChange={(event) => {
            if (event.target.files) addFiles(event.target.files)
            event.target.value = ""
          }}
        />
        <UploadCloudIcon className="text-primary mb-3 size-7" />
        <div className="text-sm leading-5 font-medium">{title}</div>
        <div className="text-muted-foreground mt-1 max-w-sm text-sm leading-6">
          {description}
        </div>
        <Button
          type="button"
          variant="outline"
          size="sm"
          className="mt-4 rounded-md"
          disabled={disabled}
          onClick={(event) => {
            event.stopPropagation()
            inputRef.current?.click()
          }}
        >
          {browseText}
        </Button>
      </div>

      <div className="space-y-2">
        {items.length === 0 ? (
          <div className="text-muted-foreground border-border rounded-lg border px-3 py-2 text-sm">
            {emptyText}
          </div>
        ) : (
          items.map((item) => {
            const status = item.status ?? "pending"
            const progress = item.progress ?? (status === "success" ? 100 : 0)

            return (
              <div
                key={item.id}
                className="border-border bg-card flex items-center gap-3 rounded-lg border p-3"
              >
                <FileIcon filename={item.file.name} className="shrink-0" />
                <div className="min-w-0 flex-1">
                  <div className="flex min-w-0 items-center gap-2">
                    <span className="truncate text-sm font-medium">
                      {item.file.name}
                    </span>
                    <span className="text-muted-foreground shrink-0 text-xs">
                      {formatBytes(item.file.size)}
                    </span>
                  </div>
                  {status === "uploading" ? (
                    <Progress value={progress} className="mt-2 h-1.5" />
                  ) : item.error ? (
                    <p className="text-destructive mt-1 text-xs">
                      {item.error}
                    </p>
                  ) : null}
                </div>
                {status === "uploading" ? (
                  <Loader2Icon className="text-muted-foreground size-4 animate-spin" />
                ) : status === "success" ? (
                  <CheckCircle2Icon className="text-primary size-4" />
                ) : status === "error" ? (
                  <XCircleIcon className="text-destructive size-4" />
                ) : null}
                <Button
                  type="button"
                  variant="ghost"
                  size="icon"
                  className="size-8 rounded-md"
                  onClick={() => removeItem(item.id)}
                  aria-label={`Remove ${item.file.name}`}
                >
                  <Trash2Icon className="size-4" />
                </Button>
              </div>
            )
          })
        )}
      </div>
    </div>
  )
}

Usage

import { FileUpload } from "@/components/ui/file-upload"
 
export function Example() {
  return (
    <FileUpload
      accept=".csv,.md,.pdf"
      maxFiles={5}
      maxSizeMB={25}
      onFilesAdded={(files) => console.log(files)}
    />
  )
}

Props

PropTypeDefault
valueFileUploadItem[]-
onValueChange(items: FileUploadItem[]) => void-
onFilesAdded(files: File[]) => void-
acceptstring-
maxFilesnumber5
maxSizeMBnumber25
multiplebooleantrue
disabledbooleanfalse