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í
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
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
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
}
/>
);
};Preset de galería
Mosaicos construidos a partir de la vista previa de cada archivo, con un visor a pantalla completa sobre el conjunto.
import { useState } from "react";
import { ES_LABELS, type StoredFile } from "uploaderkit";
import { GalleryUploader } 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: 1800 });
/** Tiles from each file's own preview, hover actions, the add tile as the
* dropzone, and a full-screen viewer over the whole set. */
export const GalleryCase = ({ locale }: { locale: Locale }) => {
const [stored, setStored] = useState<StoredFile[]>([]);
return (
<Split
main={
<GalleryUploader
scopes={demoScopes}
scope="demo-image"
entityId="gallery"
strategy={strategy}
stored={stored}
labels={locale === "es" ? ES_LABELS : undefined}
onUploaded={(files) => setStored((cur) => [...cur, ...files])}
onRemoveStored={(file) =>
setStored((cur) => cur.filter((f) => f.key !== file.key))
}
/>
}
aside={
stored.length > 0 ? (
<ResultPanel
label={`StoredFile[] · ${stored.length}`}
data={stored.map((f) => f.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
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
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.
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.
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.
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>
);
};