uploaderkit@2.0.0

Playground

Demostraciones en vivo del paquete publicado. Cada ejemplo importa la versión exacta disponible en npm.

Un solo archivo

Una zona, un archivo. Validación, carga y el descriptor almacenado que devuelve el servidor.

Documento

Selecciona un archivo o arrástralo aquí

.pdf

Ctrl+U
BasicCase.tsx
import { useState } from "react";
import { ES_LABELS, type StoredFile } from "uploaderkit";
import { Uploader } from "uploaderkit/ui";

import type { Locale } from "@/lib/i18n/config";
import { demoScopes } from "@/lib/playground/scopes";

import { ResultPanel, Split } from "./_shared";
import { createFakeStrategy } from "./fakeStrategy";

const strategy = createFakeStrategy();

/** One zone, one file. The default everything: validate → upload → done. */
export const BasicCase = ({ locale }: { locale: Locale }) => {
  const [saved, setSaved] = useState<StoredFile[]>([]);
  return (
    <Split
      main={
        <Uploader
          scopes={demoScopes}
          scope="demo-document"
          entityId="basic"
          strategy={strategy}
          label={locale === "es" ? "Documento" : "Document"}
          shortcut="mod+u"
          labels={locale === "es" ? ES_LABELS : undefined}
          onUploaded={(stored) => setSaved((prev) => [...prev, ...stored])}
        />
      }
      aside={
        saved.length > 0 ? (
          <ResultPanel label="StoredFile" data={saved.at(-1)} />
        ) : undefined
      }
    />
  );
};

Varios archivos

Progreso por lote, cancelación por archivo y un máximo de archivos aplicado antes de enviar nada.

Imágenes (máximo 4)

Selecciona un archivo o arrástralo aquí

.png · .jpg · .jpeg · .webp

MultipleCase.tsx
import { useState } from "react";
import { ES_LABELS, type StoredFile } from "uploaderkit";
import { Uploader } from "uploaderkit/ui";

import type { Locale } from "@/lib/i18n/config";
import { demoScopes } from "@/lib/playground/scopes";

import { ResultPanel, Split } from "./_shared";
import { createFakeStrategy } from "./fakeStrategy";

const strategy = createFakeStrategy({ duration: 2600 });

/** Many files: batch progress, per-file abort, a `maxFiles` cap, and the
 * stored list living outside the uploader. */
export const MultipleCase = ({ locale }: { locale: Locale }) => {
  const [saved, setSaved] = useState<StoredFile[]>([]);
  return (
    <Split
      main={
        <Uploader
          scopes={demoScopes}
          scope="demo-image"
          entityId="multi"
          strategy={strategy}
          multiple
          maxFiles={4}
          label={locale === "es" ? "Imágenes (máximo 4)" : "Images (up to 4)"}
          labels={locale === "es" ? ES_LABELS : undefined}
          stored={saved}
          onRemoveStored={(file) =>
            setSaved((prev) => prev.filter((f) => f.key !== file.key))
          }
          onUploaded={(stored) => setSaved((prev) => [...prev, ...stored])}
        />
      }
      aside={
        saved.length > 0 ? (
          <ResultPanel
            label={`StoredFile[] · ${saved.length}`}
            data={saved.map((f) => f.key)}
          />
        ) : undefined
      }
    />
  );
};

Preset de avatar

La imagen es el control: suelta una encima, se comprime a 512px y reemplaza la anterior.

Ricardo Tapia

Toca la foto o suelta una imagen encima

AvatarCase.tsx
import { useState } from "react";
import { ES_LABELS } from "uploaderkit";
import { AvatarUploader } from "uploaderkit/presets";

import type { Locale } from "@/lib/i18n/config";
import { demoScopes } from "@/lib/playground/scopes";

import { ResultPanel, Split } from "./_shared";
import { createFakeStrategy } from "./fakeStrategy";

const strategy = createFakeStrategy({ duration: 2200 });

/** The avatar recipe as one component: the picture is the control, a drop
 * lands on it, a ring closes while it uploads. */
export const AvatarCase = ({ locale }: { locale: Locale }) => {
  const [url, setUrl] = useState<string | null>(null);
  const [key, setKey] = useState<string | null>(null);
  return (
    <Split
      main={
        <div className="flex items-center gap-5">
          <AvatarUploader
            scopes={demoScopes}
            scope="demo-avatar"
            entityId="user-42"
            strategy={strategy}
            src={url}
            fallback="RT"
            size={96}
            labels={locale === "es" ? ES_LABELS : undefined}
            onUploaded={(stored) => {
              setUrl(stored.url);
              setKey(stored.key);
            }}
            onRemove={() => {
              setUrl(null);
              setKey(null);
            }}
          />
          <div>
            <p className="text-sm font-medium text-gray-800">Ricardo Tapia</p>
            <p className="text-xs text-gray-500">
              {locale === "es"
                ? "Toca la foto o suelta una imagen encima"
                : "Click the picture or drop an image on it"}
            </p>
          </div>
        </div>
      }
      aside={
        key ? <ResultPanel label="StoredFile.key" data={key} /> : undefined
      }
    />
  );
};

Validación

Extensión incorrecta, archivo demasiado grande y un archivo renombrado detectado por su firma binaria, todo antes de subir.

PDF estricto

Selecciona un archivo o arrástralo aquí

.pdf · máximo 1 MB · firma binaria verificada

ValidationCase.tsx
import { useState } from "react";
import { ES_LABELS } from "uploaderkit";
import { Uploader } from "uploaderkit/ui";

import type { Locale } from "@/lib/i18n/config";
import { demoScopes } from "@/lib/playground/scopes";

import { ResultPanel, Split } from "./_shared";
import { createFakeStrategy } from "./fakeStrategy";

const ok = createFakeStrategy();
const failing = createFakeStrategy({
  duration: 900,
  failWith: "The server rejected the file",
});

/** Wrong extension, oversized file and a spoofed signature are caught before a
 * byte leaves; toggle the checkbox to see a server that answers an error too. */
export const ValidationCase = ({ locale }: { locale: Locale }) => {
  const [serverFails, setServerFails] = useState(false);
  const [rejections, setRejections] = useState<string[]>([]);
  const es = locale === "es";
  return (
    <Split
      main={
        <>
          <label className="flex cursor-pointer items-center gap-2 text-sm text-gray-700">
            <input
              type="checkbox"
              checked={serverFails}
              onChange={(e) => setServerFails(e.target.checked)}
            />
            {es
              ? "Simular rechazo del servidor"
              : "Simulate a server rejection"}
          </label>
          <Uploader
            scopes={demoScopes}
            scope="demo-strict-pdf"
            entityId="validation"
            strategy={serverFails ? failing : ok}
            label={es ? "PDF estricto" : "Strict PDF"}
            description={
              es
                ? ".pdf · máximo 1 MB · firma binaria verificada"
                : ".pdf · up to 1 MB · binary signature verified"
            }
            labels={es ? ES_LABELS : undefined}
            onError={(message) => setRejections((prev) => [...prev, message])}
          />
        </>
      }
      aside={
        rejections.length > 0 ? (
          <ResultPanel label="onError" data={rejections} />
        ) : undefined
      }
    />
  );
};

Reintentos y concurrencia

Una red inestable simulada que falla dos veces por archivo; el reintento con backoff lo absorbe y un límite de concurrencia pone en cola el resto.

Imágenes sobre una red inestable

Selecciona un archivo o arrástralo aquí

Suelta varias — máximo 2 en vuelo

RetryCase.tsx
import { useMemo, useState } from "react";
import { ES_LABELS } from "uploaderkit";
import { Uploader } from "uploaderkit/ui";

import type { Locale } from "@/lib/i18n/config";
import { demoScopes } from "@/lib/playground/scopes";

import { Split } from "./_shared";
import { createFakeStrategy } from "./fakeStrategy";

/** A flaky network fails the first two attempts per file. With retry the
 * hook absorbs both behind exponential backoff; `concurrency: 2` queues the
 * rest. Remounting on toggle resets the attempt counters. */
export const RetryCase = ({ locale }: { locale: Locale }) => {
  const [withRetry, setWithRetry] = useState(true);
  const strategy = useMemo(
    () => createFakeStrategy({ duration: 700, failTimes: 2 }),
    [],
  );
  const es = locale === "es";
  return (
    <Split
      main={
        <>
          <label className="flex cursor-pointer items-center gap-2 font-mono text-sm text-gray-700">
            <input
              type="checkbox"
              checked={withRetry}
              onChange={(e) => setWithRetry(e.target.checked)}
            />
            retry: {"{ attempts: 3, backoffMs: 400 }"}
          </label>
          <Uploader
            key={String(withRetry)}
            scopes={demoScopes}
            scope="demo-image"
            entityId="retry"
            strategy={strategy}
            multiple
            concurrency={2}
            {...(withRetry ? { retry: { attempts: 3, backoffMs: 400 } } : {})}
            label={
              es
                ? "Imágenes sobre una red inestable"
                : "Images over a flaky network"
            }
            description={
              es
                ? "Suelta varias — máximo 2 en vuelo"
                : "Drop several — at most 2 in flight"
            }
            labels={es ? ES_LABELS : undefined}
          />
        </>
      }
    />
  );
};

Visor de archivos por separado

FileViewer sin uploader: imágenes, PDF y un respaldo para lo demás, reutilizable donde la app ya tenga archivos almacenados.

Abre cualquiera y navega con las flechas: el visor recibe el conjunto completo.

ViewerCase.tsx
import { Eye, FileText, Image as ImageIcon } from "lucide-react";
import { useState } from "react";
import { ES_LABELS } from "uploaderkit";
import { FileViewer, type ViewableFile } from "uploaderkit/ui";

import type { Locale } from "@/lib/i18n/config";

/**
 * `FileViewer` on its own, with no uploader attached.
 *
 * It is a standalone overlay: hand it a file (or a set to page through) and it
 * renders images, PDFs and the fallback for anything it cannot display inline.
 * That makes it reusable anywhere an app already has stored files — a record
 * detail, an attachments table — not only next to an upload zone.
 *
 * `resolveUrl` is the hook for authenticated reads: it runs per open, so a
 * short-lived signed URL is fetched when the viewer needs it rather than
 * embedded in the page.
 */
const FILES: ViewableFile[] = [
  {
    url: "https://images.unsplash.com/photo-1447933601403-0c6688de566e?w=1200",
    fileName: "coffee.jpg",
    mimeType: "image/jpeg",
  },
  {
    url: "https://images.unsplash.com/photo-1511920170033-f8396924c348?w=1200",
    fileName: "espresso.jpg",
    mimeType: "image/jpeg",
  },
  {
    url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
    fileName: "invoice.pdf",
    mimeType: "application/pdf",
  },
];

export const ViewerCase = ({ locale }: { locale: Locale }) => {
  const [open, setOpen] = useState<ViewableFile | null>(null);
  const es = locale === "es";

  return (
    <div className="space-y-4">
      <p className="text-fd-muted-foreground text-sm">
        {es
          ? "Abre cualquiera y navega con las flechas: el visor recibe el conjunto completo."
          : "Open any of them and use the arrow keys: the viewer receives the whole set."}
      </p>

      <ul className="grid gap-2 sm:grid-cols-3">
        {FILES.map((file) => (
          <li key={file.url}>
            <button
              type="button"
              onClick={() => setOpen(file)}
              className="border-fd-border hover:border-fd-primary hover:text-fd-primary btn-lift flex w-full items-center gap-2 rounded-lg border px-3 py-2.5 text-left text-sm"
            >
              {file.mimeType === "application/pdf" ? (
                <FileText className="size-4 shrink-0" />
              ) : (
                <ImageIcon className="size-4 shrink-0" />
              )}
              <span className="min-w-0 flex-1 truncate font-mono text-xs">
                {file.fileName}
              </span>
              <Eye className="size-3.5 shrink-0 opacity-60" />
            </button>
          </li>
        ))}
      </ul>

      <FileViewer
        file={open}
        files={FILES}
        onClose={() => setOpen(null)}
        labels={es ? ES_LABELS : undefined}
      />
    </div>
  );
};

Headless

Sin la UI incluida: el hook controla una interfaz completamente propia, con su propio anillo de progreso y controles.

    HeadlessCase.tsx
    import { useRef } from "react";
    import { formatFileSize } from "uploaderkit";
    import { useUploader } from "uploaderkit/react";
    
    import { demoScopes } from "@/lib/playground/scopes";
    
    import { createFakeStrategy } from "./fakeStrategy";
    
    const strategy = createFakeStrategy({ duration: 2200 });
    
    /** No shipped UI: the hook drives a fully custom interface — manual `upload()`,
     * a per-file progress ring and an abort button. */
    export const HeadlessCase = () => {
      const inputRef = useRef<HTMLInputElement>(null);
      const uploader = useUploader({
        scopes: demoScopes,
        scope: "demo-image",
        entityId: "headless",
        strategy,
        multiple: true,
        uploadOn: "manual",
      });
      const btn = "rounded-full px-4 py-2 text-sm font-medium";
    
      return (
        <div className="space-y-4">
          <div className="flex flex-wrap items-center gap-2">
            <input
              ref={inputRef}
              type="file"
              accept={uploader.accept}
              multiple
              className="hidden"
              onChange={(e) => {
                if (e.target.files) void uploader.addFiles(e.target.files);
                e.target.value = "";
              }}
            />
            <button
              type="button"
              onClick={() => inputRef.current?.click()}
              className={`${btn} bg-[#0e7490] text-white hover:bg-[#155e75]`}
            >
              Choose images
            </button>
            <button
              type="button"
              onClick={() => void uploader.upload()}
              disabled={uploader.isUploading || uploader.files.length === 0}
              className={`${btn} border border-[#0e7490] text-[#0e7490] disabled:opacity-40`}
            >
              Upload all
            </button>
            {uploader.isUploading && (
              <button
                type="button"
                onClick={() => uploader.abort()}
                className="text-sm text-red-600 hover:underline"
              >
                Cancel
              </button>
            )}
          </div>
          <ul className="space-y-1">
            {uploader.files.map((file) => (
              <li
                key={file.id}
                className="flex items-center gap-3 rounded-lg bg-cyan-50 px-3 py-2 text-sm"
              >
                <span
                  className="grid h-9 w-9 shrink-0 place-items-center rounded-full text-xs font-semibold text-white"
                  style={{
                    background: `conic-gradient(#0e7490 ${file.progress * 3.6}deg, #cffafe 0deg)`,
                  }}
                >
                  {file.status === "success" ? "✓" : file.progress}
                </span>
                <span className="min-w-0 flex-1 truncate">{file.file.name}</span>
                <span className="text-xs text-gray-500">
                  {formatFileSize(file.file.size)}
                </span>
                <button
                  type="button"
                  onClick={() => uploader.removeFile(file.id)}
                  className="text-xs text-gray-400 hover:text-red-600"
                >
    
                </button>
              </li>
            ))}
          </ul>
        </div>
      );
    };

    Ida y vuelta real al servidor

    Este caso envía a un route handler de este sitio respaldado por el proveedor en memoria. Nada se persiste.

    Imagen de producto

    Selecciona un archivo o arrástralo aquí

    PNG, JPG o WebP hasta 2 MB. Se reduce a 1280px antes de subir.

    Factura en PDF

    Selecciona un archivo o arrástralo aquí

    Solo PDF, hasta 2 MB, máximo dos archivos.

    Lo que devolvió el servidor

    Nada subido aún. El panel se llena con el descriptor almacenado.

    Se guarda en memoria y se descarta de inmediato — nada se persiste.

    ServerCase.tsx
    import { useState } from "react";
    import { ES_LABELS, type StoredFile } from "uploaderkit";
    import { createXhrUploadStrategy } from "uploaderkit/react";
    import { Uploader } from "uploaderkit/ui";
    
    import type { Locale } from "@/lib/i18n/config";
    import { demoScopes } from "@/lib/playground/scopes";
    
    const COPY = {
      en: {
        imageLabel: "Product image",
        imageHint:
          "PNG, JPG or WebP up to 2 MB. Downscaled to 1280px before upload.",
        docLabel: "Invoice PDF",
        docHint: "PDF only, up to 2 MB, two files max.",
        result: "What the server returned",
        empty: "Nothing uploaded yet. The panel fills with the stored descriptor.",
        note: "Stored in memory and discarded immediately — nothing is persisted.",
        failed: "Upload rejected",
      },
      es: {
        imageLabel: "Imagen de producto",
        imageHint: "PNG, JPG o WebP hasta 2 MB. Se reduce a 1280px antes de subir.",
        docLabel: "Factura en PDF",
        docHint: "Solo PDF, hasta 2 MB, máximo dos archivos.",
        result: "Lo que devolvió el servidor",
        empty: "Nada subido aún. El panel se llena con el descriptor almacenado.",
        note: "Se guarda en memoria y se descarta de inmediato — nada se persiste.",
        failed: "Subida rechazada",
      },
    } as const;
    
    /**
     * The one case that posts to the real route handler, backed by the package's
     * in-memory provider; every other case uses `createFakeStrategy`. Each upload is a self-contained
     * round trip: the result panel shows the descriptor the server returned rather
     * than a persistent gallery, because on serverless nothing survives the
     * invocation and a gallery would look broken.
     */
    export const ServerCase = ({ locale }: { locale: Locale }) => {
      const t = COPY[locale];
      const [stored, setStored] = useState<StoredFile[]>([]);
      const [error, setError] = useState<string | null>(null);
    
      const strategy = createXhrUploadStrategy({
        endpoint: "/api/playground/storage",
      });
    
      const common = {
        scopes: demoScopes,
        entityId: "demo",
        strategy,
        labels: locale === "es" ? ES_LABELS : undefined,
        onUploaded: (files: StoredFile[]) => {
          setError(null);
          setStored((prev) => [...files, ...prev].slice(0, 6));
        },
        onError: setError,
      };
    
      return (
        <div className="grid gap-8 lg:grid-cols-2">
          <div className="space-y-8">
            <Uploader
              {...common}
              scope="demo-image"
              label={t.imageLabel}
              description={t.imageHint}
            />
            <Uploader
              {...common}
              scope="demo-document"
              label={t.docLabel}
              description={t.docHint}
              multiple
            />
          </div>
    
          <div>
            <h3 className="text-sm font-semibold">{t.result}</h3>
            {error ? (
              <p className="mt-3 rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
                {t.failed}: {error}
              </p>
            ) : null}
            {stored.length === 0 ? (
              <p className="text-fd-muted-foreground mt-3 text-sm">{t.empty}</p>
            ) : (
              <pre className="bg-fd-muted mt-3 max-h-96 overflow-auto rounded-lg p-4 text-xs">
                {JSON.stringify(stored, null, 2)}
              </pre>
            )}
            <p className="text-fd-muted-foreground mt-4 text-xs">{t.note}</p>
          </div>
        </div>
      );
    };

    On this page