uploaderkit 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +16 -0
  3. package/README.es.md +1140 -0
  4. package/README.md +1135 -0
  5. package/dist/FileViewer-ChTf7-uY.d.cts +49 -0
  6. package/dist/FileViewer-nuD-GEdJ.d.ts +49 -0
  7. package/dist/adapters/gcs.cjs +83 -0
  8. package/dist/adapters/gcs.cjs.map +1 -0
  9. package/dist/adapters/gcs.d.cts +31 -0
  10. package/dist/adapters/gcs.d.ts +31 -0
  11. package/dist/adapters/gcs.js +81 -0
  12. package/dist/adapters/gcs.js.map +1 -0
  13. package/dist/adapters/memory.cjs +27 -0
  14. package/dist/adapters/memory.cjs.map +1 -0
  15. package/dist/adapters/memory.d.cts +18 -0
  16. package/dist/adapters/memory.d.ts +18 -0
  17. package/dist/adapters/memory.js +25 -0
  18. package/dist/adapters/memory.js.map +1 -0
  19. package/dist/adapters/s3.cjs +75 -0
  20. package/dist/adapters/s3.cjs.map +1 -0
  21. package/dist/adapters/s3.d.cts +27 -0
  22. package/dist/adapters/s3.d.ts +27 -0
  23. package/dist/adapters/s3.js +73 -0
  24. package/dist/adapters/s3.js.map +1 -0
  25. package/dist/chunk-3FI44IOW.js +150 -0
  26. package/dist/chunk-3FI44IOW.js.map +1 -0
  27. package/dist/chunk-H7BRW5IY.js +308 -0
  28. package/dist/chunk-H7BRW5IY.js.map +1 -0
  29. package/dist/chunk-PDKAF4GX.js +707 -0
  30. package/dist/chunk-PDKAF4GX.js.map +1 -0
  31. package/dist/chunk-PTEX7F4R.js +309 -0
  32. package/dist/chunk-PTEX7F4R.js.map +1 -0
  33. package/dist/chunk-T5YZG6JW.js +511 -0
  34. package/dist/chunk-T5YZG6JW.js.map +1 -0
  35. package/dist/index.cjs +220 -27
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.cts +57 -170
  38. package/dist/index.d.ts +57 -170
  39. package/dist/index.js +1 -324
  40. package/dist/index.js.map +1 -1
  41. package/dist/presets.cjs +1665 -0
  42. package/dist/presets.cjs.map +1 -0
  43. package/dist/presets.d.cts +100 -0
  44. package/dist/presets.d.ts +100 -0
  45. package/dist/presets.js +405 -0
  46. package/dist/presets.js.map +1 -0
  47. package/dist/react.cjs +981 -0
  48. package/dist/react.cjs.map +1 -0
  49. package/dist/react.d.cts +106 -0
  50. package/dist/react.d.ts +106 -0
  51. package/dist/react.js +5 -0
  52. package/dist/react.js.map +1 -0
  53. package/dist/server/express.cjs +179 -0
  54. package/dist/server/express.cjs.map +1 -0
  55. package/dist/server/express.d.cts +64 -0
  56. package/dist/server/express.d.ts +64 -0
  57. package/dist/server/express.js +111 -0
  58. package/dist/server/express.js.map +1 -0
  59. package/dist/server/next.cjs +165 -0
  60. package/dist/server/next.cjs.map +1 -0
  61. package/dist/server/next.d.cts +38 -0
  62. package/dist/server/next.d.ts +38 -0
  63. package/dist/server/next.js +107 -0
  64. package/dist/server/next.js.map +1 -0
  65. package/dist/server.cjs +550 -0
  66. package/dist/server.cjs.map +1 -0
  67. package/dist/server.d.cts +19 -0
  68. package/dist/server.d.ts +19 -0
  69. package/dist/server.js +80 -0
  70. package/dist/server.js.map +1 -0
  71. package/dist/storage-CYkSHWZX.d.cts +133 -0
  72. package/dist/storage-Qc9epG0G.d.ts +133 -0
  73. package/dist/types-BSlJJwti.d.cts +341 -0
  74. package/dist/types-BSlJJwti.d.ts +341 -0
  75. package/dist/ui.cjs +2325 -0
  76. package/dist/ui.cjs.map +1 -0
  77. package/dist/ui.d.cts +331 -0
  78. package/dist/ui.d.ts +331 -0
  79. package/dist/ui.js +749 -0
  80. package/dist/ui.js.map +1 -0
  81. package/dist/useSlottedUploader-BzT5jV8c.d.cts +139 -0
  82. package/dist/useSlottedUploader-IkG5RhEO.d.ts +139 -0
  83. package/dist/useUploader-BiBdS-7y.d.cts +117 -0
  84. package/dist/useUploader-CQHpj_oI.d.ts +117 -0
  85. package/package.json +189 -3
  86. package/tailwind.css +80 -0
package/README.es.md ADDED
@@ -0,0 +1,1140 @@
1
+ <div align="center">
2
+
3
+ # uploaderkit
4
+
5
+ **Subida de archivos para React y Node, desde un solo contrato.**
6
+ Scopes que ambos lados validan, un uploader headless con progreso / abort / compresión, y un servicio de almacenamiento en servidor con providers intercambiables.
7
+
8
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
9
+ [![Node](https://img.shields.io/badge/node-%3E%3D18-339933.svg)](https://nodejs.org/)
10
+ [![React](https://img.shields.io/badge/react-%5E18%20%7C%7C%20%5E19-61dafb.svg)](https://react.dev/)
11
+ [![Tailwind](https://img.shields.io/badge/tailwindcss-v4-38bdf8.svg)](https://tailwindcss.com/)
12
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178c6.svg)](https://www.typescriptlang.org/)
13
+
14
+ [🇬🇧 English](./README.md) | **🇲🇽 Español**
15
+
16
+ </div>
17
+
18
+ ---
19
+
20
+ ## Tabla de contenidos
21
+
22
+ - [Características](#características)
23
+ - [Inicio rápido](#inicio-rápido)
24
+ - [Instalación](#instalación)
25
+ - [Configuración de Tailwind v4](#configuración-de-tailwind-v4)
26
+ - [Scopes — el contrato](#scopes--el-contrato)
27
+ - [Definir scopes](#definir-scopes)
28
+ - [Replace — nunca dejar un archivo muerto](#replace--nunca-dejar-un-archivo-muerto)
29
+ - [Cliente](#cliente)
30
+ - [`useUploader`](#useuploader)
31
+ - [Disparo de la subida — `select` vs `manual`](#disparo-de-la-subida--select-vs-manual)
32
+ - [Reintentos y concurrencia](#reintentos-y-concurrencia)
33
+ - [Renombrar a la entrada](#renombrar-a-la-entrada)
34
+ - [Nombres de archivo seguros](#nombres-de-archivo-seguros)
35
+ - [Estrategias de subida](#estrategias-de-subida)
36
+ - [Validación](#validación)
37
+ - [Compresión de imágenes](#compresión-de-imágenes)
38
+ - [Slots con nombre (`useSlottedUploader`)](#slots-con-nombre-useslotteduploader)
39
+ - [Componentes de UI](#componentes-de-ui)
40
+ - [`Uploader`](#uploader)
41
+ - [`SlottedUploader`](#slotteduploader)
42
+ - [Confirmaciones](#confirmaciones)
43
+ - [Vista previa (`FileViewer`)](#vista-previa-fileviewer)
44
+ - [Leer un archivo guardado](#leer-un-archivo-guardado)
45
+ - [Soltar para reemplazar](#soltar-para-reemplazar)
46
+ - [Labels — todo el copy es reemplazable](#labels--todo-el-copy-es-reemplazable)
47
+ - [Theming](#theming)
48
+ - [Headless por completo](#headless-por-completo)
49
+ - [Presets](#presets)
50
+ - [Servidor](#servidor)
51
+ - [`createStorage`](#createstorage)
52
+ - [Express](#express)
53
+ - [Next.js App Router](#nextjs-app-router)
54
+ - [Cifrado](#cifrado)
55
+ - [Providers de almacenamiento](#providers-de-almacenamiento)
56
+ - [Feedback para el desarrollador](#feedback-para-el-desarrollador)
57
+ - [Subpath exports](#subpath-exports)
58
+ - [Licencia](#licencia)
59
+
60
+ ---
61
+
62
+ ## Características
63
+
64
+ - **Un contrato, los dos lados** — un registro de scopes declara dónde aterriza un archivo, quién puede leerlo, qué se acepta y qué tan grande puede ser. El navegador y el servidor validan contra el mismo objeto, así que un rechazo nunca es una sorpresa al final de una subida de 40 MB.
65
+ - **Headless primero** — `useUploader` es dueño de la selección, la validación, la compresión, el progreso por archivo, el abort y el estado de error; no renderiza nada. `/ui` es una piel opcional encima, así que una app con su propio design system no pierde comportamiento al saltársela.
66
+ - **Validación en el cliente antes del primer byte** — extensión, tamaño y magic numbers, para que un `.exe` renombrado a `.pdf` nunca salga de la máquina.
67
+ - **Progreso de subida real** — la estrategia por defecto usa XHR porque `fetch` sigue sin tener progreso de subida usable en los navegadores. Toda subida en vuelo se puede abortar, por archivo o todas de golpe.
68
+ - **Compresión de imágenes en el scope** — declara `compress` y las imágenes se reescalan y reencodean antes de viajar. El EXIF (GPS, cámara) se pierde en el proceso.
69
+ - **Transporte intercambiable** — el `UploadStrategy` es una sola función `(file, scope, entityId, { onProgress, signal })`. Cambia el endpoint, el header de auth o el protocolo completo sin tocar el estado del hook.
70
+ - **Servicio de almacenamiento en servidor** — `createStorage` vuelve a correr la misma validación, cifra los scopes que lo piden y responde URLs firmadas con expiración para los objetos privados.
71
+ - **Guardas en el arranque** — un scope privado sobre un provider que no puede firmar, o un scope cifrado sin un cipher inyectado, lanza al construir. El deploy falla en voz alta en vez de dar 500 en la primera subida.
72
+ - **Adaptadores de framework** — handlers estructurales para Express (la app es dueña de multer) y para el App Router de Next.js (`Request`/`Response` nativos). No se arrastra ninguna dependencia de framework.
73
+ - **Adaptadores de provider** — Google Cloud Storage (esquema de dos buckets), cualquier backend compatible con S3 (AWS, Cloudflare R2, Backblaze B2, MinIO, Wasabi) y un provider en memoria para pruebas. Todos son peers opcionales: elegir GCS nunca instala el SDK de AWS.
74
+ - **Slots con nombre** — `SlottedUploader` llena un archivo por posición nombrada (hoja membretada, identificación, constancia fiscal); un drop masivo rutea cada archivo a su slot y lo renombra para que resubir sobrescriba en su lugar.
75
+ - **Sin cipher por defecto** — los scopes privados declaran `encrypt: true` y la app inyecta los `CryptoHooks`. `createAesGcmCrypto` está disponible como implementación de referencia.
76
+ - **Tú eliges cuándo dispara la subida** — `uploadOn: 'select'` manda en cuanto un archivo válido aterriza; `'manual'` retiene los archivos hasta `upload()` — el flujo de formulario. `onUploadStart` anuncia el momento en que un batch sale.
77
+ - **Retry con backoff + tope de concurrencia** — resiliencia opt-in para redes inestables: las fallas transitorias de la estrategia se reintentan con backoff exponencial, y los batches grandes se encolan tras un límite de concurrencia.
78
+ - **Diálogos de confirmación integrados** — `confirmRemove` / `confirmReplace` ponen un segundo paso accesible antes de las acciones destructivas (el foco cae en cancelar), y `ConfirmDialog` se exporta para uso de la app.
79
+ - **Paste y captura de cámara** — la zona con foco acepta un screenshot pegado, y `capture` abre la cámara del móvil directamente.
80
+ - **Copy traducible** — todos los strings visibles fluyen por un objeto de labels, en el cliente Y en el servidor. Inglés por defecto, `ES_LABELS` incluido, cualquier idioma con un override parcial.
81
+ - **Tema re-brandeable** — la capa con estilos lee variables CSS `--color-ui-*` así un solo override en `:root` re-brandea toda la capa con estilos.
82
+ - **Tamaños, iconos y motion** — `size='sm' | 'md'` compacta cada fila y zona, `icon` cambia (o quita) el glifo de la zona, y toda la superficie anima: las filas hacen fade-in, el drag escala la zona, los overlays entran y salen con transición, el indicador de subida pulsa.
83
+ - **Touch-first por defecto** — en pointers coarse la zona se lee como objetivo de tap ("Toca para elegir un archivo") con feedback de presión, en vez de anunciar un drag que nadie puede hacer; `capture` abre la cámara directo.
84
+
85
+ ---
86
+
87
+ ## Inicio rápido
88
+
89
+ ```bash
90
+ pnpm add uploaderkit react react-dom
91
+ ```
92
+
93
+ ```ts
94
+ // scopes.ts — lo importan el cliente Y el servidor
95
+ import { defineScopes, MB } from 'uploaderkit'
96
+
97
+ export const scopes = defineScopes({
98
+ 'invoice-evidence': {
99
+ path: (invoiceId, file) => `Invoices/${invoiceId}/evidence/${file.name}`,
100
+ visibility: 'private',
101
+ accept: ['pdf', 'png', 'jpg'],
102
+ maxBytes: 8 * MB,
103
+ encrypt: true,
104
+ },
105
+ })
106
+ ```
107
+
108
+ ```tsx
109
+ // cliente
110
+ import { Uploader } from 'uploaderkit/ui'
111
+ import { createXhrUploadStrategy } from 'uploaderkit/react'
112
+
113
+ const strategy = createXhrUploadStrategy({ endpoint: `${apiUrl}/storage` })
114
+
115
+ ;<Uploader
116
+ scopes={scopes}
117
+ scope='invoice-evidence'
118
+ entityId={invoiceId}
119
+ strategy={strategy}
120
+ onUploaded={stored => saveToDb(stored)}
121
+ />
122
+ ```
123
+
124
+ ```ts
125
+ // servidor
126
+ import { createStorage } from 'uploaderkit/server'
127
+ import { createMemoryProvider } from 'uploaderkit/adapters/memory'
128
+
129
+ export const storage = createStorage({
130
+ scopes,
131
+ provider: createMemoryProvider(),
132
+ crypto: { encrypt, decrypt },
133
+ })
134
+ ```
135
+
136
+ ---
137
+
138
+ ## Instalación
139
+
140
+ ```bash
141
+ pnpm add uploaderkit
142
+ # peer, solo si usas /react o /ui
143
+ pnpm add react react-dom
144
+ ```
145
+
146
+ Peers opcionales — instala únicamente el provider que uses:
147
+
148
+ ```bash
149
+ pnpm add @google-cloud/storage # /adapters/gcs
150
+ pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner # /adapters/s3
151
+ ```
152
+
153
+ Las entradas de servidor no dependen de nada más que de este paquete: el
154
+ adaptador de Express calza estructuralmente con Express 4 y 5, y el de Next.js
155
+ usa la Fetch API.
156
+
157
+ ---
158
+
159
+ ## Configuración de Tailwind v4
160
+
161
+ Solo hace falta para `/ui`. Registra las clases del paquete una vez para que
162
+ Tailwind las genere:
163
+
164
+ ```css
165
+ /* app/globals.css */
166
+ @import 'tailwindcss';
167
+ @import 'uploaderkit/tailwind.css';
168
+ ```
169
+
170
+ Los hooks de `/react` no llevan estilos, así que una app headless se salta esto
171
+ por completo.
172
+
173
+ ---
174
+
175
+ ## Scopes — el contrato
176
+
177
+ Un **scope** es un destino con nombre: la única fuente de verdad sobre dónde
178
+ aterriza un archivo, quién puede leerlo y qué se acepta ahí. El registro es un
179
+ objeto plano que importan el cliente y el servidor, y eso es lo que hace que
180
+ las dos validaciones coincidan por construcción.
181
+
182
+ ### Definir scopes
183
+
184
+ ```ts
185
+ import { defineScopes, MB } from 'uploaderkit'
186
+
187
+ export const scopes = defineScopes({
188
+ 'user-avatar': {
189
+ path: userId => `Users/${userId}/avatar`,
190
+ visibility: 'public',
191
+ accept: ['png', 'jpg', 'jpeg', 'webp'],
192
+ maxBytes: 5 * MB,
193
+ category: 'image',
194
+ compress: { maxWidth: 512, quality: 0.8, stripExif: true },
195
+ },
196
+ })
197
+ ```
198
+
199
+ | Campo | Significado |
200
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
201
+ | `path` | `(entityId, file) => string` — la key de almacenamiento. Tú controlas colisiones y forma de carpetas. |
202
+ | `visibility` | `'public'` (URL directa) o `'private'` (solo URL firmada con expiración). |
203
+ | `accept` | Extensiones permitidas aquí. Más angosto que el preset de la categoría, nunca más ancho. |
204
+ | `maxBytes` | Tope duro. Se exportan los helpers `KB` / `MB` / `GB`. |
205
+ | `category` | `'image' \| 'pdf' \| 'document' \| 'data' \| 'video' \| 'audio' \| 'certificate' \| 'key' \| 'any'`. Elige el preset que decide si se leen los magic numbers. |
206
+ | `encrypt` | Entrega los bytes al cipher de la app antes de que salgan del servidor. |
207
+ | `compress` | Pipeline de imagen en el cliente: `maxWidth`, `maxHeight`, `quality`, `stripExif` (por defecto `true`). |
208
+ | `maxFiles` | Cuántos archivos puede tener una entidad aquí. Default `1`; el uploader deriva `multiple` de esto. |
209
+ | `replace` | Qué borra una subida. Derivado por default — ver [Replace](#replace--nunca-dejar-un-archivo-muerto). |
210
+ | `prefix` | `(entityId) => string` — objetos que un replace `'entity'` puede borrar. Default: la carpeta de la key resuelta. |
211
+ | `metadata` | Etiquetas libres que se reenvían al provider cuando las soporta. |
212
+
213
+ `defineScopes` devuelve un `ScopeRegistry`: `names`, `get(name)`, `has(name)` y
214
+ `accept(name)` — este último es el string listo para `<input accept>`.
215
+
216
+ ### Replace — nunca dejar un archivo muerto
217
+
218
+ El object storage no limpia solo. Un scope cuya key incluye el nombre del
219
+ archivo escribe un objeto NUEVO cada vez, así que re-subir un logo deja el
220
+ anterior pagando renta para siempre. `replace` decide eso, y su default se
221
+ deriva para que no haya prop que olvidar:
222
+
223
+ | El scope | `replace` derivado | Por qué |
224
+ | ------------------------------------------------- | ------------------ | -------------------------------------------------------------- |
225
+ | `maxFiles: 1` (default), la key lleva `file.name` | `'entity'` | Cada subida cae en una key nueva — hay que barrer la anterior. |
226
+ | `maxFiles: 1`, la key ignora `file.name` | `'key'` | La key es estable; el provider ya sobrescribe en su lugar. |
227
+ | `maxFiles > 1` | `'key'` | Es una colección: los hermanos son el punto. |
228
+
229
+ Declararlo explícito solo sirve para salirse: `replace: false` conserva todas
230
+ las versiones.
231
+
232
+ El barrido `'entity'` corre **después** de un put exitoso y borra todo lo que
233
+ esté bajo el prefijo de la entidad y no sea la key nueva. Dos guardas evitan
234
+ que alcance de más, ambas al momento de `defineScopes`:
235
+
236
+ - `replace: 'entity'` junto con `maxFiles > 1` truena. Un scope no puede
237
+ guardar una colección y borrarla en cada subida.
238
+ - Dos scopes cuyas carpetas se solapan truenan si alguno barre, así que subir
239
+ un avatar nunca puede borrar los documentos del mismo usuario. Dale a cada
240
+ uno su carpeta, o acota uno con `prefix`.
241
+
242
+ ---
243
+
244
+ ## Cliente
245
+
246
+ ### `useUploader`
247
+
248
+ La máquina de estados headless: selección → validación → (compresión) → subida
249
+ con progreso y abort. No renderiza nada; tú renderizas `files` como la pantalla
250
+ lo necesite.
251
+
252
+ ```tsx
253
+ import { useUploader } from 'uploaderkit/react'
254
+
255
+ const { files, accept, addFiles, upload, abort, isUploading, hasPending } =
256
+ useUploader({
257
+ scopes,
258
+ scope: 'invoice-evidence',
259
+ entityId: invoiceId,
260
+ strategy,
261
+ multiple: true,
262
+ maxFiles: 3,
263
+ uploadOn: 'manual', // el default — ver "Disparo de la subida"
264
+ onUploadStart: files => setSending(true),
265
+ onUploaded: stored => saveToDb(stored),
266
+ onError: message => toast.error(message),
267
+ retry: { attempts: 3, backoffMs: 500 },
268
+ concurrency: 3,
269
+ })
270
+
271
+ <input type='file' accept={accept} onChange={e => addFiles(e.target.files!)} />
272
+ ```
273
+
274
+ Cada entrada de `files` es un `UploaderFile`:
275
+
276
+ | Campo | Significado |
277
+ | ---------- | ------------------------------------------------------------------------ |
278
+ | `id` | Id estable para la fila; también el argumento de `abort` y `removeFile`. |
279
+ | `file` | El `File` tal como se seleccionó. |
280
+ | `status` | `'idle' \| 'uploading' \| 'success' \| 'error'`. |
281
+ | `progress` | 0–100 mientras sube, 100 al terminar. |
282
+ | `error` | Mensaje humano, de la validación o de la falla de la estrategia. |
283
+ | `preview` | Object URL para imágenes — miniatura local antes de subir. |
284
+ | `stored` | El `StoredFile` que confirmó el servidor. |
285
+
286
+ `upload()` manda todos los archivos que siguen en `idle` y resuelve con los
287
+ confirmados. `abort(id)` cancela una subida, `abort()` las cancela todas — un
288
+ archivo abortado vuelve a `idle`, no a `error`, para que el usuario reintente
289
+ sin limpiar nada. `hasPending` es `true` mientras algún archivo espera en
290
+ `idle` — la bandera que lee un botón de submit.
291
+
292
+ Omite `strategy` para selección y validación solo locales.
293
+
294
+ ### Disparo de la subida — `select` vs `manual`
295
+
296
+ No toda pantalla quiere el mismo momento. El contrato lo hace explícito en vez
297
+ de fijar un solo comportamiento:
298
+
299
+ - **`uploadOn: 'select'`** — el archivo viaja en cuanto valida. Las pantallas
300
+ de arrastrar / adjuntar y listo: evidencias, avatares, galerías. El
301
+ `Uploader` con estilos usa este default.
302
+ - **`uploadOn: 'manual'`** (default del hook) — los archivos esperan en `idle`
303
+ hasta que la app llama `upload()`. El flujo de formulario: todos los campos
304
+ más el documento se confirman como una sola acción en el submit.
305
+
306
+ ```tsx
307
+ const uploader = useUploader({ scopes, scope, entityId, strategy }) // manual
308
+
309
+ const onSubmit = async (event: FormEvent) => {
310
+ event.preventDefault()
311
+ if (!form.valid || !uploader.hasPending) return
312
+ const stored = await uploader.upload() // dispara aquí, con el submit
313
+ await saveRecord({ ...form.values, file: stored[0] })
314
+ }
315
+ ```
316
+
317
+ A nivel de los componentes con estilos la elección es un contrato de tres —
318
+ `uploadOn: 'select' | 'submit' | 'manual'` — un modo por tipo de pantalla:
319
+
320
+ | Modo | Quién manda | Úsalo para |
321
+ | ---------- | -------------------------------------- | ---------------------------------------------------------------------------------------- |
322
+ | `'select'` | La zona, en cuanto aterriza un archivo | Avatares, reemplazos rápidos — el archivo ES la acción |
323
+ | `'submit'` | El formulario, vía `controllerRef` | Todo archivo que depende del resto de un form para tener sentido (documentos, catálogos) |
324
+ | `'manual'` | El botón propio de la zona | Evidencias y flujos puntuales sin form alrededor — suelta ahora, manda cuando quieras |
325
+
326
+ Prefiere `'submit'` siempre que el archivo pertenezca a un formulario que el
327
+ usuario puede abandonar. Un scope de key estable sobrescribe en cada put, así
328
+ que una subida que dispara al seleccionar **ya** cambió lo que la entidad
329
+ sirve — el logo de un cliente, la foto de un producto — aunque el operador le
330
+ dé Cancelar después. Diferir es lo que hace que "cancelar" signifique
331
+ cancelar. (`SlottedUploader` solo ofrece `'select'` y `'submit'`: no tiene
332
+ superficie de botón, así que un slot en espera bajo `'manual'` nunca podría
333
+ salir.)
334
+
335
+ El cableado de `'submit'`:
336
+
337
+ ```tsx
338
+ const uploaderRef = useRef<UploaderController | null>(null)
339
+ const [staged, setStaged] = useState(false)
340
+
341
+ const onSubmit = async () => {
342
+ if (uploaderRef.current?.hasPending) await uploaderRef.current.upload()
343
+ await handleSubmit(save)() // lee los valores que la subida acaba de escribir
344
+ }
345
+
346
+ <Uploader
347
+ {...props}
348
+ uploadOn='submit'
349
+ controllerRef={uploaderRef}
350
+ onPendingChange={setStaged}
351
+ />
352
+ <button disabled={!isDirty && !staged}>Guardar</button>
353
+ ```
354
+
355
+ Dos detalles fáciles de equivocar:
356
+
357
+ - Vacía la cola **antes** de `handleSubmit(...)()`, no dentro del callback de
358
+ submit. Una subida aterriza en el form vía `setValue`, y un callback que ya
359
+ recibió su argumento `data` leería los valores de antes.
360
+ - `onPendingChange` es lo que le avisa al form que tiene trabajo sin mandar.
361
+ Un archivo en espera nunca toca los campos, así que un botón de guardar
362
+ condicionado solo a `isDirty` se queda deshabilitado en un formulario limpio
363
+ al que el usuario acaba de soltarle un archivo.
364
+
365
+ Bajo `'submit'` la zona no renderiza botón de subida propio: dos formas de
366
+ mandar el mismo batch es una de más, y la del formulario es la que sabe si el
367
+ resto de los campos son válidos.
368
+
369
+ `onUploadStart(files)` se dispara cuando un batch sale de verdad — desde
370
+ cualquiera de los dos triggers — para que un formulario entre a su estado
371
+ "enviando" en el momento real, no en la selección.
372
+
373
+ ### Reintentos y concurrencia
374
+
375
+ Ambos opt-in, ambos viviendo por completo dentro del hook:
376
+
377
+ ```ts
378
+ retry: { attempts: 3, backoffMs: 500 }, // o el atajo: retry: 3
379
+ concurrency: 3,
380
+ ```
381
+
382
+ - **`retry`** re-ejecuta una llamada fallida de la estrategia antes de mostrar
383
+ el error, con backoff exponencial (`backoffMs`, luego ×2 por intento). Los
384
+ aborts nunca se reintentan, y las fallas de validación nunca llegan a la
385
+ estrategia. El progreso de la fila se reinicia entre intentos; el usuario
386
+ solo ve un error cuando falla el último.
387
+ - **`concurrency`** limita cuántos archivos suben a la vez; el resto se
388
+ encola. Treinta fotos en un móvil ya no son treinta XHR simultáneos.
389
+
390
+ ### Renombrar a la entrada
391
+
392
+ `rename` reescribe el nombre de cada archivo antes de entrar a la máquina —
393
+ un folio, un input del cliente, un slug. Corre **antes de la validación** (un
394
+ rename que rompe la extensión se rechaza como cualquier archivo inválido), y
395
+ el `path` del scope lee el nombre nuevo al armar la key de almacenamiento:
396
+
397
+ ```ts
398
+ useUploader({
399
+ scopes,
400
+ scope: 'invoice-evidence',
401
+ entityId,
402
+ strategy,
403
+ rename: file => `${folio}-${file.name}`,
404
+ })
405
+ ```
406
+
407
+ Los slots con nombre ya renombran a `{slot}.{ext}` — ese contrato sigue
408
+ siendo suyo.
409
+
410
+ ### Nombres de archivo seguros
411
+
412
+ `sanitizeFileName` convierte el nombre del usuario en un segmento de key seguro
413
+ — ASCII, minúsculas, una sola extensión, sin sintaxis de ruta. Llámalo
414
+ **dentro** del `path()` de tu scope, para que cliente y servidor deriven la
415
+ misma key:
416
+
417
+ ```ts
418
+ path: (id, file) => `Docs/${id}/${sanitizeFileName(file.name)}`
419
+ ```
420
+
421
+ La guarda de traversal detrás de `resolveKey` juzga por segmento de ruta, no
422
+ por substring: `Screenshot … 4.18.54 p.m..png` carga `..` sin ser traversal
423
+ jamás, y un screenshot de macOS es el caso común, no uno de esquina. La guarda
424
+ es el respaldo; el sanitizador es el fix.
425
+
426
+ ### Estrategias de subida
427
+
428
+ Una estrategia es el transporte físico de un archivo. El hook es dueño del
429
+ estado, la estrategia es dueña de los bytes:
430
+
431
+ ```ts
432
+ type UploadStrategy = (
433
+ file: File,
434
+ scope: string,
435
+ entityId: string,
436
+ options: { onProgress: (percent: number) => void; signal: AbortSignal }
437
+ ) => Promise<StoredFile>
438
+ ```
439
+
440
+ La de fábrica hace un POST multipart a
441
+ `POST {endpoint}/{scope}/{entityId}/upload`, que es exactamente lo que exponen
442
+ los adaptadores de framework de más abajo:
443
+
444
+ ```ts
445
+ import { createXhrUploadStrategy } from 'uploaderkit/react'
446
+
447
+ const strategy = createXhrUploadStrategy({
448
+ endpoint: `${apiUrl}/storage`,
449
+ // Se evalúa por subida, así un JWT rotativo se lee al momento de mandar.
450
+ headers: () => ({ Authorization: `Bearer ${getToken()}` }),
451
+ fieldName: 'file',
452
+ // Sesiones por cookie: la api responde en otro origen, así que el navegador
453
+ // descarta la cookie de sesión salvo que la petición la pida.
454
+ credentials: 'include',
455
+ })
456
+ ```
457
+
458
+ Escribir la tuya es una sola función — PUT firmado directo al bucket, un
459
+ protocolo resumible, una cola. Abortar debe rechazar con un error llamado
460
+ `AbortError`; el hook mapea eso a `idle` en vez de `error`.
461
+
462
+ ### Validación
463
+
464
+ Corre en el cliente para dar feedback y otra vez en el servidor por seguridad.
465
+ Tres chequeos, en orden: **extensión** contra el `accept` del scope, **tamaño**
466
+ contra `maxBytes`, y **magic numbers** — los primeros bytes del archivo, para
467
+ que un `.exe` renombrado a `.pdf` se rechace antes de viajar.
468
+
469
+ Los mensajes son en español y seguros de mostrar al usuario por diseño; la
470
+ falla aterriza en `files[i].error` y en `onError`.
471
+
472
+ ### Compresión de imágenes
473
+
474
+ Cuando el scope declara `compress`, las imágenes se reescalan y reencodean en
475
+ un canvas antes de que la estrategia las vea:
476
+
477
+ ```ts
478
+ compress: { maxWidth: 512, maxHeight: 512, quality: 0.8, stripExif: true }
479
+ ```
480
+
481
+ El EXIF se pierde como efecto colateral inherente al reencode — las fotos de
482
+ cámara traen coordenadas GPS, y un bucket público es el peor lugar para eso. El
483
+ pipeline cae de vuelta al archivo original siempre que no puede ayudar, así que
484
+ nunca hace fallar una subida. `compressImage(file, options)` se exporta para
485
+ usos sueltos.
486
+
487
+ ### Slots con nombre (`useSlottedUploader`)
488
+
489
+ Para formularios donde cada posición lleva exactamente un documento. Este hook
490
+ envuelve `useUploader` tal cual: solo decide **en qué slot** cae un archivo y lo
491
+ renombra a `{slot}.{ext}`, para que el `path` del scope dé una key estable y
492
+ resubir sobrescriba en su lugar.
493
+
494
+ ```tsx
495
+ const { slots, accept, addFiles, addToSlot, removeSlot, abort, isUploading } =
496
+ useSlottedUploader({
497
+ scopes,
498
+ scope: 'company-identity',
499
+ entityId: companyId,
500
+ strategy,
501
+ slots: [
502
+ { id: 'letterhead', label: 'Hoja membretada', extensions: ['pdf'] },
503
+ { id: 'logo', label: 'Logo', extensions: ['png', 'svg'] },
504
+ ],
505
+ value: slotFiles, // SlottedFile[] — la persistencia es tuya
506
+ onChange: setSlotFiles,
507
+ matchBy: 'extension', // o 'name', o un matcher propio en `match`
508
+ })
509
+ ```
510
+
511
+ Las subidas son **controladas**: `value`/`onChange` dejan la persistencia en
512
+ quien llama, y el hook mezcla las subidas que el padre todavía no absorbe, así
513
+ dos drops rápidos no pueden hacer que el estado controlado pierda uno.
514
+
515
+ `matchBy: 'extension'` (el default) prefiere un slot vacío, así soltar tres
516
+ archivos llena tres posiciones; `'name'` calza un archivo nombrado como su slot
517
+ (`letterhead-a4.pdf` → slot `letterhead-a4`). `slotOfStored` recupera el slot de
518
+ un archivo persistido cuando rehidratas desde la base de datos.
519
+
520
+ ---
521
+
522
+ ## Componentes de UI
523
+
524
+ Ambos componentes son pieles sobre los hooks — misma validación, compresión,
525
+ progreso y abort. Importa `uploaderkit/tailwind.css` una vez (ver
526
+ [Configuración de Tailwind v4](#configuración-de-tailwind-v4)).
527
+
528
+ ### `Uploader`
529
+
530
+ Una zona de drop, uno o varios archivos dentro.
531
+
532
+ ```tsx
533
+ import { Uploader } from 'uploaderkit/ui'
534
+
535
+ ;<Uploader
536
+ scopes={scopes}
537
+ scope='invoice-evidence'
538
+ entityId={invoiceId}
539
+ strategy={strategy}
540
+ multiple
541
+ maxFiles={3}
542
+ label='Evidencia'
543
+ description='PDF o foto, hasta 8 MB'
544
+ stored={saved} // ya persistidos, del lado que diga filesPosition
545
+ filesPosition='below' // que la zona de drop no se deslice hacia abajo
546
+ onRemoveStored={forget} // borrar en remoto sigue siendo decisión tuya
547
+ confirmRemove // segundo paso en diálogo; o { title, message }
548
+ onUploaded={persist}
549
+ resolveViewUrl={file => api.signedUrl(file.key)}
550
+ capture='environment' // móvil: abre la cámara trasera directo
551
+ />
552
+ ```
553
+
554
+ Acepta todas las opciones de `useUploader` más las props de presentación de
555
+ arriba, y pone `uploadOn` en `'select'` por defecto. `'manual'` le da a la
556
+ zona un botón de subida para los archivos que esperan; `'submit'` entrega el
557
+ envío a tu formulario a través de `controllerRef` (ver
558
+ [Disparo de la subida](#disparo-de-la-subida--select-vs-manual)).
559
+ `resolveViewUrl` vuelve a firmar un objeto privado justo antes de
560
+ previsualizarlo, para el caso en que la URL guardada ya expiró.
561
+
562
+ `filesPosition` decide de qué lado de la zona de drop se acomodan las listas de
563
+ archivos. Por defecto `'above'`, el layout de siempre; `'below'` mantiene la
564
+ zona anclada, lo que importa cuando los archivos se agregan de a uno — si no,
565
+ cada agregado empuja hacia abajo el blanco al que el usuario está apuntando.
566
+
567
+ `renderFiles` reemplaza las filas mismas. Recibe los archivos persistidos, los
568
+ de la sesión con su estado vivo, las filas default ya construidas y los
569
+ callbacks de ver/quitar — así una pantalla renderiza un grid de miniaturas, una
570
+ línea de resumen o un conteo dentro de su propia tarjeta:
571
+
572
+ ```tsx
573
+ <Uploader
574
+ {...props}
575
+ filesPosition='below'
576
+ renderFiles={({ staged, stored, isEmpty, remove }) =>
577
+ isEmpty ? null : (
578
+ <ul className='grid grid-cols-3 gap-2'>
579
+ {stored.map(file => (
580
+ <li key={file.key}>{file.fileName}</li>
581
+ ))}
582
+ {staged.map(file => (
583
+ <li key={file.id} onClick={() => remove(file.id)}>
584
+ {file.file.name} · {file.status}
585
+ </li>
586
+ ))}
587
+ </ul>
588
+ )
589
+ }
590
+ />
591
+ ```
592
+
593
+ Cambia las filas, no su lugar: el resultado sigue renderizando del lado de
594
+ `filesPosition`. Para un layout del que la zona misma es parte — archivos AL
595
+ LADO de la zona, todo dentro de tu propio marco — sáltate esta piel y compón
596
+ `useUploader` con los `Dropzone`, `FileItem` y `StoredFileItem` exportados.
597
+ Nada de aquí falta allá.
598
+
599
+ La zona también acepta un archivo **pegado** mientras tiene el foco (los
600
+ screenshots aterrizan como subidas), y `capture` hace que un dispositivo táctil
601
+ ofrezca su cámara en vez del picker. En un pointer coarse el prompt cambia al
602
+ copy de tap (`labels.tapPrompt`) con feedback de presión — un usuario de
603
+ celular nunca lee sobre arrastrar.
604
+
605
+ Perillas de presentación: `size='sm'` compacta la zona y todas las filas;
606
+ `icon` reemplaza el glifo de la zona con cualquier nodo (`icon={null}` lo
607
+ quita). Colores y radios salen de las variables del tema — ver
608
+ [Theming](#theming).
609
+
610
+ `shortcut='mod+u'` liga una tecla global (⌘U / Ctrl+U) que abre el picker y
611
+ renderiza un hint `kbd` pequeño dentro de la zona, para que el usuario lo
612
+ descubra. Nunca dispara mientras se escribe en un campo, y dos zonas con el
613
+ mismo combo avisan en desarrollo — con varios uploaders en pantalla, dale a
614
+ cada uno el suyo.
615
+
616
+ ### `SlottedUploader`
617
+
618
+ Una fila de estado por slot más una zona de drop masiva cuyo matcher rutea cada
619
+ archivo:
620
+
621
+ ```tsx
622
+ import { SlottedUploader } from 'uploaderkit/ui'
623
+
624
+ ;<SlottedUploader
625
+ scopes={scopes}
626
+ scope='company-identity'
627
+ entityId={companyId}
628
+ strategy={strategy}
629
+ title='Documentos de la empresa'
630
+ slots={[
631
+ { id: 'letterhead', label: 'Hoja membretada', extensions: ['pdf'] },
632
+ {
633
+ id: 'logo',
634
+ label: 'Logo',
635
+ extensions: ['png', 'svg'],
636
+ hint: 'Fondo transparente',
637
+ },
638
+ ]}
639
+ value={slotFiles}
640
+ onChange={setSlotFiles}
641
+ confirmRemove // diálogo antes de olvidar un slot lleno
642
+ confirmReplace // diálogo que nombra ambos archivos antes de sobrescribir
643
+ hideDropzone={false}
644
+ />
645
+ ```
646
+
647
+ `confirmReplace` intercepta el archivo elegido _después_ del pick — así el
648
+ diálogo puede nombrar lo que se va a perder y lo que lo reemplaza. Ambas props
649
+ aceptan `true` para la copia por defecto o `{ title, message }` para
650
+ sobrescribirla.
651
+
652
+ **Filas en espera.** Con `uploadOn: 'submit'` el archivo elegido no viaja:
653
+ descansa en su fila con miniatura, nombre y _listo para subir_, más Reemplazar y
654
+ Quitar, hasta que el formulario llama a `controllerRef.current.upload()`. El
655
+ punto de estado se pone ámbar para decirlo. El nombre que se muestra es el de
656
+ almacenamiento — el archivo se renombra a `{slot}.{ext}` antes de entrar a la
657
+ máquina, y es el que la entidad va a servir.
658
+
659
+ **Quitar borra; el historial se declara.** Pasa un `removeStrategy` — `createRemoveStrategy({ endpoint, headers, credentials })`, el espejo DELETE del transporte de subida (`DELETE {endpoint}/{scope}/{entityId}` con `{ key }`) — y un quitar confirmado borra el objeto del storage **por sí solo**, igual en `Uploader` que en `SlottedUploader`. `onRemoveStored(stored)` sigue disparándose para la contabilidad de la app (limpiar la referencia en DB), entregado ANTES del `onChange`. La referencia se olvida aunque el borrado falle — un puntero colgante es peor que un huérfano — y un borrado rechazado sale por `onError`. La excepción es contrato del scope, no decisión del cliente: márcalo `keepOnRemove: true` y tanto los componentes saltan el borrado como el `storage.remove` del server responde `false` — historial aplicado donde ningún cliente lo puede saltar.
660
+
661
+ **Idioma.** El default del kit es inglés. Una app en español opta una sola vez en la raíz — `<UploaderProvider language='es'>` (exportado de `/react` y `/ui`) — y todo componente y hook debajo, incluidos los mensajes de validación como `maxFilesReached`, habla español; el prop `labels` por componente sigue ganando para reescrituras puntuales.
662
+
663
+ ### Confirmaciones
664
+
665
+ Las acciones destructivas sobre archivos llevan un segundo paso: un diálogo
666
+ accesible (portal, foco atrapado, **el foco cae en cancelar** para que un
667
+ Enter perdido nunca destruya nada; Escape y el fondo cancelan).
668
+ `ConfirmDialog` se exporta para envolver tus propias acciones en la misma UX:
669
+
670
+ ```tsx
671
+ import { ConfirmDialog } from 'uploaderkit/ui'
672
+
673
+ ;<ConfirmDialog
674
+ open={confirming}
675
+ title='Eliminar expediente'
676
+ message='Se borrarán también sus documentos.'
677
+ variant='danger'
678
+ onConfirm={destroy}
679
+ onCancel={() => setConfirming(false)}
680
+ />
681
+ ```
682
+
683
+ ### Vista previa (`FileViewer`)
684
+
685
+ Ambos uploaders integran el visor de pantalla completa; se exporta standalone
686
+ para cualquier pantalla que persista un `StoredFile`. Hace portal a `<body>`
687
+ (ningún stacking context ancestro puede atraparlo), atrapa el foco mientras
688
+ está abierto y lo restaura al cerrar, y bloquea el scroll de la página detrás.
689
+ Cuando `resolveUrl` falla — una firma expirada, una conexión caída — el visor
690
+ muestra un error con botón de reintento en vez de cargar por siempre. En
691
+ viewports angostos un PDF se renderiza como tarjeta de descarga en vez de un
692
+ frame embebido (iOS Safari congela los PDF embebidos).
693
+
694
+ Pasa `files` (la colección) junto a `file` (el que se clickeó) y el visor se
695
+ vuelve galería: flechas laterales, `←`/`→` en el teclado, contador `2 / 5` y —
696
+ en pointers finos — un pie que muestra los atajos (`Esc`, `←` `→`), para que
697
+ nadie tenga que adivinarlos:
698
+
699
+ ```tsx
700
+ <FileViewer file={viendo} files={imagenesGuardadas} onClose={cerrar} />
701
+ ```
702
+
703
+ Teclado: `Esc` cierra, `←`/`→` recorren la galería, `D` descarga y `O` abre el
704
+ archivo en pestaña — cada uno anunciado en el pie con pointers finos. Nunca se
705
+ reclama una tecla con modificador, así que `⌘D` sigue guardando en marcadores.
706
+
707
+ `renderError` reemplaza el panel de "no se pudo cargar" integrado. Recibe el
708
+ archivo que falló más un `retry` que vuelve a resolverlo, para que un panel
709
+ propio conserve la recuperación que da el default:
710
+
711
+ ```tsx
712
+ <FileViewer
713
+ file={viendo}
714
+ onClose={cerrar}
715
+ renderError={({ file, retry }) => (
716
+ <MiPanelDeError name={file.fileName} onRetry={retry} />
717
+ )}
718
+ />
719
+ ```
720
+
721
+ `useFileViewer` es dueño del estado abrir/cerrar que si no repetirías en cada
722
+ pantalla:
723
+
724
+ ```tsx
725
+ const viewer = useFileViewer({ resolveUrl })
726
+
727
+ <button onClick={() => viewer.open(stored)}>Ver</button>
728
+ <FileViewer {...viewer.viewerProps} />
729
+ ```
730
+
731
+ ### Leer un archivo guardado
732
+
733
+ Un `<img>` o un `<iframe>` no pueden mandar header `Authorization`, así que un
734
+ objeto privado o cifrado nunca renderiza desde su url cruda. Dos helpers hacen
735
+ la lectura autenticada por ti — misma regla, dos formatos de salida:
736
+
737
+ ```tsx
738
+ import {
739
+ createBlobUrlResolver,
740
+ createBytesResolver,
741
+ } from 'uploaderkit/react'
742
+
743
+ // Para el visor: hace fetch con los headers de la app y devuelve un object URL.
744
+ const resolveViewUrl = createBlobUrlResolver({
745
+ baseUrl: apiUrl,
746
+ headers: () => ({ Authorization: `Bearer ${getToken()}` }),
747
+ credentials: 'include',
748
+ })
749
+
750
+ <Uploader {...props} resolveViewUrl={resolveViewUrl} />
751
+
752
+ // Para código que procesa el archivo en vez de mostrarlo.
753
+ const readBytes = createBytesResolver({ baseUrl: apiUrl, headers })
754
+ const pdf = await PDFDocument.load(await readBytes(stored.url))
755
+ ```
756
+
757
+ La regla que comparten es de **origen, no de forma**: una url que sirve
758
+ `baseUrl` — relativa a la app, o absoluta en el mismo origen — es tuya y viaja
759
+ con tus headers y tus `credentials`; una en un origen **ajeno** ya es
760
+ alcanzable y se pide pelada, porque el token nunca debe ir a un host de
761
+ terceros. El `encryptedUrl` de tu servidor normalmente persiste una url
762
+ absoluta que apunta de vuelta a tu propia ruta `/view`: esa cuenta como tuya. Leer los bytes por tu propio endpoint es además
763
+ lo que le ahorra a un bucket público su propia política de CORS: un `<img>`
764
+ está exento de CORS, un `fetch` por bytes no.
765
+
766
+ `viewUrlFileName(url)` recupera el nombre visible de una url `/view?key=…`.
767
+
768
+ ### Soltar para reemplazar
769
+
770
+ Una fila llena del `SlottedUploader` es en sí misma un drop target: arrastrar
771
+ un archivo encima la ilumina con la pill &ldquo;Suelta para reemplazar&rdquo;,
772
+ y el drop pasa por el mismo diálogo de `confirmReplace` que el botón. Una fila
773
+ vacía acepta el drop como llenado directo — sin pasar por el matcher de la
774
+ zona masiva.
775
+
776
+ ### Labels — todo el copy es reemplazable
777
+
778
+ Todo el texto visible fluye por un solo objeto, `UploaderLabels`. El inglés es
779
+ el default; el español viene como `ES_LABELS`. El idioma se elige una sola vez
780
+ en la raíz:
781
+
782
+ ```tsx
783
+ import { UploaderProvider } from 'uploaderkit/react'
784
+
785
+ ;<UploaderProvider language='es'>
786
+ <App />
787
+ </UploaderProvider>
788
+ ```
789
+
790
+ Cualquier override parcial gana sobre esa base, por componente o por hook:
791
+
792
+ ```tsx
793
+ <Uploader {...props} labels={{ dropPrompt: 'Suelta aquí tu factura' }} />
794
+ ```
795
+
796
+ #### El copy llega más lejos que los componentes
797
+
798
+ El objeto no es solo para el markup: también redacta los mensajes de validación
799
+ y las respuestas HTTP del servidor, que es lo que evita que un mismo rechazo
800
+ llegue en dos idiomas:
801
+
802
+ ```ts
803
+ import { ES_LABELS, validateForScope } from 'uploaderkit'
804
+
805
+ const result = await validateForScope(scopes, 'facturas', file, ES_LABELS)
806
+ // result.message viene en español
807
+ ```
808
+
809
+ | Superficie | Cómo recibe el copy |
810
+ | ----------------------------------------------- | ---------------------------------- |
811
+ | `Uploader`, `SlottedUploader`, presets | prop `labels`, sobre el provider |
812
+ | `useUploader`, `useSlottedUploader` | opción `labels`, sobre el provider |
813
+ | `validateFile`, `validateFiles` | `labels` en `ValidationOptions` |
814
+ | `validateForScope` | 4º argumento |
815
+ | `createXhrUploadStrategy`, los view resolvers | opción `labels` |
816
+ | `createStorage` — y ambos adapters de framework | opción `labels`, una sola vez |
817
+
818
+ En el servidor una sola opción cubre todo el round trip: `createStorage`
819
+ resuelve el copy y lo republica como `storage.labels`, que es justo de donde
820
+ leen `createExpressStorageHandlers` y `createNextStorageHandlers`. Una ruta no
821
+ puede responder en un idioma distinto al del servicio que tiene detrás.
822
+
823
+ ```ts
824
+ import { ES_LABELS } from 'uploaderkit'
825
+
826
+ const storage = createStorage({ scopes, provider, labels: ES_LABELS })
827
+ // 401 → "No autorizado"; una subida rechazada → el mismo texto 422 que vio el browser
828
+ ```
829
+
830
+ `ScopeError` es la excepción, a propósito: señala un error de cableado, se
831
+ queda en inglés y nunca se serializa a un cliente.
832
+
833
+ Ver `UploaderLabels` para la lista completa de keys.
834
+
835
+ ### Theming
836
+
837
+ La capa con estilos lee variables CSS `--color-ui-*` / `--radius-ui*`,
838
+ declaradas con defaults en `tailwind.css`. Una app re-brandea toda la capa
839
+ con estilos con un solo override:
840
+
841
+ ```css
842
+ :root {
843
+ --color-ui-primary: #c41e3a;
844
+ --color-ui-primary-hover: #8b1529;
845
+ --radius-ui: 0.25rem;
846
+ }
847
+ ```
848
+
849
+ El override escopa como cualquier variable CSS: ponlo en un `div` wrapper para
850
+ re-brandear un solo uploader en vez de toda la app.
851
+
852
+ El motion viene con los componentes: las filas animan al entrar
853
+ (`--animate-ui-fade-in`), el visor y el diálogo
854
+ de confirmación hacen fade/scale al entrar y salir, el drag levanta la zona y
855
+ la presión la comprime, y el indicador del slot pulsa mientras sube. Todo es
856
+ CSS — nada que configurar, y sobreescribible desde la app para
857
+ `prefers-reduced-motion`.
858
+
859
+ `Dropzone`, `FileItem` y `FileViewer` se exportan por separado para armar otro
860
+ acomodo con las mismas piezas.
861
+
862
+ ### Headless por completo
863
+
864
+ Una app con su propio design system usa `/react` directo y no pierde nada — la
865
+ validación, la compresión, el progreso, el abort y el ruteo de slots viven en
866
+ los hooks. `/ui` existe para que una pantalla que no necesita markup propio no
867
+ tenga que escribirlo.
868
+
869
+ ---
870
+
871
+ ## Presets
872
+
873
+ Las recetas que el playground venía demostrando ya son componentes, bajo `uploaderkit/presets`. Cada uno compone `./react` y `./ui`, lee los mismos tokens `--color-ui-*` y las mismas etiquetas, y es opcional: cuando uno no encaja, `useUploader` + `Dropzone` + `FileItem` siguen siendo el piso.
874
+
875
+ | Preset | Qué es |
876
+ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
877
+ | `useDropAnywhere` + `DropAnywhereOverlay` | Toda la ventana como destino de arrastre. El hook cuenta la profundidad de `dragenter`/`dragleave` a nivel window y es el **único** que maneja `drop`, así que un archivo nunca llega dos veces; el overlay es sólo visual (`pointer-events-none`). `accept` usa la sintaxis de `<input>`. |
878
+ | `AvatarUploader` | Una foto que es su propio control: clic o arrastre encima, un anillo de progreso la rodea, `onUploaded` devuelve el `StoredFile`. Sube al seleccionar a propósito. |
879
+ | `GalleryUploader` | Una cuadrícula de miniaturas: primero los `stored` persistidos, luego los archivos en vuelo con su barrido de progreso, acciones al hover, la casilla de agregar como dropzone y `FileViewer` sobre todo el conjunto. |
880
+
881
+ ```tsx
882
+ const uploader = useUploader({ scopes, scope: 'attachments', entityId, strategy, multiple: true })
883
+ const { dragging } = useDropAnywhere({ onFiles: files => uploader.addFiles(files), accept: uploader.accept })
884
+
885
+ <DropAnywhereOverlay open={dragging} />
886
+ ```
887
+
888
+ ## Servidor
889
+
890
+ ### `createStorage`
891
+
892
+ El lado servidor del contrato: vuelve a correr la misma validación que corrió
893
+ el navegador, cifra lo que el scope declare, y habla con un `StorageProvider`.
894
+
895
+ ```ts
896
+ import { createStorage } from 'uploaderkit/server'
897
+ import { createGcsProvider } from 'uploaderkit/adapters/gcs'
898
+
899
+ const storage = createStorage({
900
+ scopes,
901
+ provider: createGcsProvider({ publicBucket, privateBucket }),
902
+ crypto: { encrypt, decrypt }, // requerido si algún scope declara `encrypt`
903
+ signedUrlTtl: 300, // segundos
904
+ })
905
+ ```
906
+
907
+ | Método | Responde |
908
+ | ------------------------------------------------ | --------------------------------------------------------------- |
909
+ | `upload({ scope, entityId, file, uploadedBy })` | Un `UploadResult`: el `StoredFile` a persistir, más `replaced`. |
910
+ | `read({ scope, key })` | Bytes crudos, descifrados cuando el scope está cifrado. |
911
+ | `remove({ scope, key })` | `true` cuando el objeto existía. |
912
+ | `signedUrl({ scope, key, download, expiresIn })` | Una URL fresca con expiración. Lanza en scopes públicos. |
913
+ | `list(prefix)` | `{ key, size }[]`. |
914
+
915
+ La construcción es defensiva: un scope privado sobre un provider que no puede
916
+ firmar, o un scope cifrado sin `crypto`, lanza un `ScopeError` **antes de la
917
+ primera petición** — mientras el deploy todavía puede fallar en voz alta.
918
+
919
+ #### Qué reemplazó una subida
920
+
921
+ `upload()` responde un `UploadResult` — un `StoredFile` más las keys que el
922
+ barrido eliminó:
923
+
924
+ ```ts
925
+ const { key, url, replaced } = await storage.upload({ scope, entityId, file })
926
+
927
+ // El bucket ya no las tiene. Lo que persististe también debe olvidarlas, o tu
928
+ // UI sigue renderizando objetos que ya no existen.
929
+ await db.files.deleteMany({ key: { $in: replaced } })
930
+ ```
931
+
932
+ `replaced` viene vacío salvo que el scope resuelva a un replace `'entity'`, y
933
+ solo lista lo que el provider confirmó borrado. El barrido corre **después** de
934
+ un put exitoso — un fallo entre los dos dejaría a la entidad sin nada — y un
935
+ delete que falla se traga: la subida que pidió quien llama sí ocurrió, y un
936
+ objeto huérfano no vale fallarla.
937
+
938
+ La `url` de un objeto público lleva una huella corta `?v=` de su contenido, así
939
+ un scope de key estable (un avatar) deja de servir la imagen anterior desde un
940
+ CDN o la caché del navegador después de sobrescribir.
941
+
942
+ #### Lecturas en streaming
943
+
944
+ `storage.readStream({ scope, key })` sirve un archivo sin sostenerlo en memoria
945
+ — por `get`, un documento de 20MB cuesta su tamaño completo en RAM por lector
946
+ concurrente. El handler `view` de Express lo pipea. Degrada con honestidad: un
947
+ provider sin `getStream`, o un scope cifrado cuyo crypto no trae
948
+ `decryptStream`, cae a la lectura bufferizada envuelta en un stream de un solo
949
+ chunk — quien llama recibe siempre la misma forma.
950
+
951
+ El trade del descifrado en streaming, dicho donde decides: el plaintext llega
952
+ al consumidor **antes** de verificar el tag GCM, así que una alteración
953
+ aparece como un stream que se rompe al final — `read()` verifica antes de
954
+ entregar un solo byte.
955
+
956
+ ### Express
957
+
958
+ Formas estructurales de request/response en vez de los tipos de Express, para
959
+ que el paquete no cargue dependencias y cualquier app de Express 4/5 las
960
+ cumpla. La app conserva la propiedad de multer:
961
+
962
+ ```ts
963
+ import { createExpressStorageHandlers } from 'uploaderkit/server/express'
964
+
965
+ const handlers = createExpressStorageHandlers(storage, {
966
+ authorize: async req => (req.user ? { userId: req.user.id } : null),
967
+ })
968
+
969
+ const upload = multer({ storage: multer.memoryStorage() })
970
+ router.post(
971
+ '/:scope/:entityId/upload',
972
+ useAuth,
973
+ upload.single('file'),
974
+ handlers.upload
975
+ )
976
+ router.delete('/:scope/:entityId', useAuth, handlers.remove)
977
+ router.get('/:scope/:entityId/signed-url', useAuth, handlers.signedUrl)
978
+ ```
979
+
980
+ `authorize` devuelve el usuario que actúa (o `{}` para "permitido") para seguir,
981
+ o `null` para responder 401.
982
+
983
+ ### Next.js App Router
984
+
985
+ La misma superficie sobre la Fetch API:
986
+
987
+ ```ts
988
+ // app/api/storage/[scope]/[entityId]/upload/route.ts
989
+ import { createNextStorageHandlers } from 'uploaderkit/server/next'
990
+
991
+ const handlers = createNextStorageHandlers(storage, {
992
+ authorize: async request => {
993
+ const session = await auth(request)
994
+ return session ? { userId: session.userId } : null
995
+ },
996
+ })
997
+
998
+ export const POST = handlers.upload
999
+ ```
1000
+
1001
+ Omitir `authorize` deja el router **abierto** — solo aceptable detrás de un
1002
+ proxy autenticado.
1003
+
1004
+ ### Cifrado
1005
+
1006
+ Un scope cifrado necesita **dos** cosas cableadas, y `createStorage` lanza al
1007
+ arrancar si falta cualquiera: el cipher y `encryptedUrl`.
1008
+
1009
+ ```ts
1010
+ const storage = createStorage({
1011
+ scopes,
1012
+ provider,
1013
+ crypto,
1014
+ // Dónde puede LEER un cliente un objeto cifrado. El bucket guarda
1015
+ // ciphertext, así que una URL firmada serviría basura — esto tiene que
1016
+ // apuntar a tu ruta autenticada de view, que descifra a la salida.
1017
+ encryptedUrl: ({ scope, entityId, key }) =>
1018
+ `/api/storage/${scope}/${entityId}/view?key=${encodeURIComponent(key)}`,
1019
+ })
1020
+ ```
1021
+
1022
+ El `StoredFile.url` de esos scopes es esa ruta, así un `<img>` o el
1023
+ `FileViewer` renderizan el archivo real. Ambos adaptadores de framework
1024
+ exponen la ruta como `handlers.view`, respondiendo los bytes descifrados con
1025
+ `Cache-Control: private, no-store` — el contenido descifrado nunca debe caer
1026
+ en un caché compartido:
1027
+
1028
+ ```ts
1029
+ // Express
1030
+ router.get('/:scope/:entityId/view', useAuth, handlers.view)
1031
+
1032
+ // Next App Router — app/api/storage/[scope]/[entityId]/view/route.ts
1033
+ export const GET = handlers.view
1034
+ ```
1035
+
1036
+ No se impone ningún cipher: un scope declara `encrypt: true` y la app inyecta
1037
+ los `CryptoHooks`. `createAesGcmCrypto` es la implementación de referencia
1038
+ (AES-256-GCM, layout `[iv 12][tag 16][ciphertext]`) para que no la escribas a
1039
+ mano:
1040
+
1041
+ ```ts
1042
+ import { createAesGcmCrypto } from 'uploaderkit/server'
1043
+
1044
+ const crypto = createAesGcmCrypto(process.env.STORAGE_KEY!) // openssl rand -hex 32
1045
+ ```
1046
+
1047
+ La llave debe ser exactamente 64 caracteres hex (32 bytes) — sin derivación
1048
+ desde passphrase a propósito, porque derivar dejaría a dos instancias corriendo
1049
+ un secreto "casi igual" y produciendo archivos mutuamente ilegibles en
1050
+ silencio.
1051
+
1052
+ Los objetos cifrados se guardan como `application/octet-stream`, así nada
1053
+ intenta renderizar texto cifrado; `read()` descifra a la salida.
1054
+
1055
+ ---
1056
+
1057
+ ## Providers de almacenamiento
1058
+
1059
+ Un provider es la traducción delgada hacia un bucket. Los tres implementan el
1060
+ mismo contrato, así que cambiar de uno a otro nunca toca los scopes ni el
1061
+ servicio.
1062
+
1063
+ ```ts
1064
+ // Google Cloud Storage — dos buckets: activos públicos, documentos privados
1065
+ import { createGcsProvider } from 'uploaderkit/adapters/gcs'
1066
+
1067
+ const provider = createGcsProvider({
1068
+ publicBucket,
1069
+ privateBucket,
1070
+ publicUrl: (bucket, key) => `https://cdn.example.com/${key}`,
1071
+ })
1072
+ ```
1073
+
1074
+ ```ts
1075
+ // Compatible con S3 — AWS, Cloudflare R2, Backblaze B2, MinIO, Wasabi
1076
+ import { createS3Provider } from 'uploaderkit/adapters/s3'
1077
+
1078
+ const provider = createS3Provider({
1079
+ client, // un S3Client de @aws-sdk/client-s3; entre backends solo cambian endpoint/credenciales
1080
+ bucket: 'uploads',
1081
+ publicUrl: key => `https://cdn.example.com/${key}`,
1082
+ })
1083
+ ```
1084
+
1085
+ ```ts
1086
+ // Pruebas y desarrollo local
1087
+ import { createMemoryProvider } from 'uploaderkit/adapters/memory'
1088
+
1089
+ const provider = createMemoryProvider() // las URLs firmadas son falsas pero cargan la expiración
1090
+ ```
1091
+
1092
+ El bucket de S3 se trata como privado y `publicUrl` mapea las keys que expone
1093
+ un CDN o un dominio público — los buckets modernos bloquean las ACL por objeto,
1094
+ así que el adaptador no puede inventarse una URL pública estable. Ambos SDK son
1095
+ peers opcionales: importar un adaptador sin su SDK instalado falla solo para la
1096
+ app que eligió ese backend.
1097
+
1098
+ ---
1099
+
1100
+ ## Feedback para el desarrollador
1101
+
1102
+ Dos niveles, para que una config rota aparezca donde todavía se puede arreglar:
1103
+
1104
+ - **`ScopeError` — lanzado, de inmediato.** Configs que nunca pueden funcionar:
1105
+ un scope malformado, un nombre de scope inexistente, ids de slot duplicados,
1106
+ un scope privado sobre un provider que no firma, un scope cifrado sin
1107
+ `CryptoHooks`. Fallan al importar o al arrancar, nunca frente a un usuario.
1108
+ Los adaptadores de framework lo relanzan en vez de serializarlo al cliente.
1109
+ - **Avisos de desarrollo — una vez por caso.** Configs que corren pero
1110
+ probablemente no son lo que querías: llamar `upload()` sin estrategia, soltar
1111
+ varios archivos en modo single, un slot que acepta extensiones que su scope
1112
+ rechaza. Van con el prefijo `[uploaderkit]`, callan en producción, y nunca
1113
+ condicionan el comportamiento.
1114
+
1115
+ Las fallas a nivel petición son una tercera familia aparte:
1116
+ `StorageRequestError` carga un status HTTP y un mensaje en español seguro de
1117
+ mostrar, que los adaptadores de framework convierten a JSON.
1118
+
1119
+ ---
1120
+
1121
+ ## Subpath exports
1122
+
1123
+ | Ruta de importación | Contenido |
1124
+ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
1125
+ | `uploaderkit` | `defineScopes`, `validateForScope`, `resolveKey`, `toAcceptAttribute`, `formatFileSize`, `KB`/`MB`/`GB`, `ScopeError`, `DEFAULT_LABELS`/`ES_LABELS`, tipos |
1126
+ | `uploaderkit/react` | `useUploader`, `useSlottedUploader`, `createXhrUploadStrategy`, `compressImage`, matchers de slot, tipos |
1127
+ | `uploaderkit/ui` | `Uploader`, `SlottedUploader`, `Dropzone`, `FileItem`, `FileViewer`, `ConfirmDialog`, `cn` |
1128
+ | `uploaderkit/server` | `createStorage`, `createAesGcmCrypto`, `StorageRequestError`, tipos |
1129
+ | `uploaderkit/server/express` | `createExpressStorageHandlers` — handlers estructurales para una app de Express que es dueña de multer |
1130
+ | `uploaderkit/server/next` | `createNextStorageHandlers` — handlers de App Router sobre la Fetch API |
1131
+ | `uploaderkit/adapters/gcs` | `createGcsProvider` — Google Cloud Storage de dos buckets (peer opcional) |
1132
+ | `uploaderkit/adapters/s3` | `createS3Provider` — AWS, R2, B2, MinIO, Wasabi (peers opcionales) |
1133
+ | `uploaderkit/adapters/memory` | `createMemoryProvider` — pruebas y desarrollo local |
1134
+ | `uploaderkit/tailwind.css` | Registro de fuente Tailwind v4 para las clases de `/ui` |
1135
+
1136
+ ---
1137
+
1138
+ ## Licencia
1139
+
1140
+ MIT © Ricardo Tapia