UI kit

RagUpload

IngestionReact · Next.js

A drag-and-drop zone for document ingestion. POSTs each file as multipart/form-data with real upload-progress bars. Supply the uploader prop to stage to S3/GCS or a presigned URL instead of a direct POST.

<RagUpload />

Live preview

Drop documents or click to upload

PDF · TXT · MD · DOCX · HTML

Installation

No npm dependencies — just React + Tailwind CSS. There are two ways to use it:

  • React — copy the source below; it calls your pipeline directly from the browser.
  • Next.js — switch to the Next.js tab in the source below; it keeps your pipeline URL + key server-side behind a Route Handler (adds a couple of .env.local vars).

Usage

usage.tsx
import { RagUpload } from "@/components/RagUpload";

export default function Example() {
  return (
    <RagUpload endpoint="https://api.example.com/ingest" />
  );
}

Props

PropTypeDefaultDescription
endpointrequiredstringIngestion URL. POSTs multipart/form-data (field "file").
acceptedTypesstring".pdf,.txt,.md,.docx,.html"Comma-separated accept list.
apiKeystringSent as X-API-Key when your serve.py has API_KEY set.
onComplete(file: File) => voidCalled when a file finishes uploading.
uploaderUploaderOverride how a file is uploaded (presigned URL, S3, testing).

Component source copy-paste ready

RagUpload.tsx
"use client";

import { useRef, useState } from "react";

interface UploadFile {
  name: string;
  size: number;
  progress: number;
  status: "uploading" | "done" | "error";
}

/** Uploads one file, reporting 0–100 progress. Resolve on success, reject on failure. */
export type Uploader = (file: File, onProgress: (pct: number) => void) => Promise<void>;

interface RagUploadProps {
  /** Ingestion endpoint. By default, POSTs multipart/form-data (field "file"). */
  endpoint: string;
  /** Comma-separated accept list. Default: ".pdf,.txt,.md,.docx,.html". */
  acceptedTypes?: string;
  /** Sent as the X-API-Key header when your serve.py has API_KEY set. */
  apiKey?: string;
  onComplete?: (file: File) => void;
  /** Override how a file is uploaded (e.g. to a presigned URL, or for testing). */
  uploader?: Uploader;
  className?: string;
}

/** Default uploader — multipart POST with real upload-progress events via XHR. */
function makeDefaultUploader(endpoint: string, apiKey?: string): Uploader {
  return (file, onProgress) =>
    new Promise<void>((resolve, reject) => {
      const form = new FormData();
      form.append("file", file);
      const xhr = new XMLHttpRequest();
      xhr.open("POST", endpoint);
      if (apiKey) xhr.setRequestHeader("X-API-Key", apiKey);
      xhr.upload.onprogress = (e) => {
        if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
      };
      xhr.onload = () => (xhr.status < 400 ? resolve() : reject(new Error(`HTTP ${xhr.status}`)));
      xhr.onerror = () => reject(new Error("network error"));
      xhr.send(form);
    });
}

/**
 * RagUpload — drag-and-drop document ingestion with a live per-file progress
 * bar. By default it POSTs each file as multipart/form-data to `endpoint`;
 * supply `uploader` to stage to S3/GCS or a presigned URL instead.
 */
export function RagUpload({
  endpoint,
  acceptedTypes = ".pdf,.txt,.md,.docx,.html",
  apiKey,
  onComplete,
  uploader,
  className = "",
}: RagUploadProps) {
  const [files, setFiles] = useState<UploadFile[]>([]);
  const [drag, setDrag] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  function patch(name: string, p: Partial<UploadFile>) {
    setFiles((f) => f.map((x) => (x.name === name ? { ...x, ...p } : x)));
  }

  function start(fileList: FileList | null) {
    if (!fileList) return;
    const upload = uploader ?? makeDefaultUploader(endpoint, apiKey);
    Array.from(fileList).forEach((file) => {
      setFiles((f) => [...f, { name: file.name, size: file.size, progress: 0, status: "uploading" }]);
      upload(file, (pct) => patch(file.name, { progress: pct }))
        .then(() => {
          patch(file.name, { progress: 100, status: "done" });
          onComplete?.(file);
        })
        .catch(() => patch(file.name, { status: "error" }));
    });
  }

  return (
    <div className={className}>
      <div
        onDragOver={(e) => {
          e.preventDefault();
          setDrag(true);
        }}
        onDragLeave={() => setDrag(false)}
        onDrop={(e) => {
          e.preventDefault();
          setDrag(false);
          start(e.dataTransfer.files);
        }}
        onClick={() => inputRef.current?.click()}
        className={`flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-10 text-center transition ${
          drag ? "border-zinc-500 bg-zinc-50 dark:bg-zinc-900" : "border-zinc-300 dark:border-zinc-700"
        }`}
      >
        <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-zinc-400">
          <path d="M12 16V4m0 0 4 4m-4-4-4 4" />
          <path d="M20 16.5A2.5 2.5 0 0 1 17.5 19h-11A2.5 2.5 0 0 1 4 16.5" />
        </svg>
        <p className="text-sm font-medium text-zinc-600 dark:text-zinc-300">Drop documents or click to upload</p>
        <p className="text-xs text-zinc-400">
          {acceptedTypes.replaceAll(".", "").toUpperCase().replaceAll(",", " · ")}
        </p>
        <input
          ref={inputRef}
          type="file"
          multiple
          accept={acceptedTypes}
          className="hidden"
          onChange={(e) => start(e.target.files)}
        />
      </div>

      {files.length > 0 && (
        <ul className="mt-3 flex flex-col gap-2">
          {files.map((f, i) => (
            <li key={i} className="rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-800">
              <div className="mb-1 flex items-center justify-between text-xs">
                <span className="truncate font-medium text-zinc-700 dark:text-zinc-200">{f.name}</span>
                <span className={f.status === "error" ? "text-red-500" : f.status === "done" ? "text-emerald-500" : "text-zinc-400"}>
                  {f.status === "done" ? "Done" : f.status === "error" ? "Failed" : `${f.progress}%`}
                </span>
              </div>
              <div className="h-1.5 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
                <div
                  className={`h-full rounded-full transition-all ${
                    f.status === "error" ? "bg-red-400" : f.status === "done" ? "bg-emerald-500" : "bg-zinc-500"
                  }`}
                  style={{ width: `${f.progress}%` }}
                />
              </div>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}