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.
- package/LICENSE +21 -0
- package/NOTICE +16 -0
- package/README.es.md +1140 -0
- package/README.md +1135 -0
- package/dist/FileViewer-ChTf7-uY.d.cts +49 -0
- package/dist/FileViewer-nuD-GEdJ.d.ts +49 -0
- package/dist/adapters/gcs.cjs +83 -0
- package/dist/adapters/gcs.cjs.map +1 -0
- package/dist/adapters/gcs.d.cts +31 -0
- package/dist/adapters/gcs.d.ts +31 -0
- package/dist/adapters/gcs.js +81 -0
- package/dist/adapters/gcs.js.map +1 -0
- package/dist/adapters/memory.cjs +27 -0
- package/dist/adapters/memory.cjs.map +1 -0
- package/dist/adapters/memory.d.cts +18 -0
- package/dist/adapters/memory.d.ts +18 -0
- package/dist/adapters/memory.js +25 -0
- package/dist/adapters/memory.js.map +1 -0
- package/dist/adapters/s3.cjs +75 -0
- package/dist/adapters/s3.cjs.map +1 -0
- package/dist/adapters/s3.d.cts +27 -0
- package/dist/adapters/s3.d.ts +27 -0
- package/dist/adapters/s3.js +73 -0
- package/dist/adapters/s3.js.map +1 -0
- package/dist/chunk-3FI44IOW.js +150 -0
- package/dist/chunk-3FI44IOW.js.map +1 -0
- package/dist/chunk-H7BRW5IY.js +308 -0
- package/dist/chunk-H7BRW5IY.js.map +1 -0
- package/dist/chunk-PDKAF4GX.js +707 -0
- package/dist/chunk-PDKAF4GX.js.map +1 -0
- package/dist/chunk-PTEX7F4R.js +309 -0
- package/dist/chunk-PTEX7F4R.js.map +1 -0
- package/dist/chunk-T5YZG6JW.js +511 -0
- package/dist/chunk-T5YZG6JW.js.map +1 -0
- package/dist/index.cjs +220 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +57 -170
- package/dist/index.d.ts +57 -170
- package/dist/index.js +1 -324
- package/dist/index.js.map +1 -1
- package/dist/presets.cjs +1665 -0
- package/dist/presets.cjs.map +1 -0
- package/dist/presets.d.cts +100 -0
- package/dist/presets.d.ts +100 -0
- package/dist/presets.js +405 -0
- package/dist/presets.js.map +1 -0
- package/dist/react.cjs +981 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +106 -0
- package/dist/react.d.ts +106 -0
- package/dist/react.js +5 -0
- package/dist/react.js.map +1 -0
- package/dist/server/express.cjs +179 -0
- package/dist/server/express.cjs.map +1 -0
- package/dist/server/express.d.cts +64 -0
- package/dist/server/express.d.ts +64 -0
- package/dist/server/express.js +111 -0
- package/dist/server/express.js.map +1 -0
- package/dist/server/next.cjs +165 -0
- package/dist/server/next.cjs.map +1 -0
- package/dist/server/next.d.cts +38 -0
- package/dist/server/next.d.ts +38 -0
- package/dist/server/next.js +107 -0
- package/dist/server/next.js.map +1 -0
- package/dist/server.cjs +550 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +19 -0
- package/dist/server.d.ts +19 -0
- package/dist/server.js +80 -0
- package/dist/server.js.map +1 -0
- package/dist/storage-CYkSHWZX.d.cts +133 -0
- package/dist/storage-Qc9epG0G.d.ts +133 -0
- package/dist/types-BSlJJwti.d.cts +341 -0
- package/dist/types-BSlJJwti.d.ts +341 -0
- package/dist/ui.cjs +2325 -0
- package/dist/ui.cjs.map +1 -0
- package/dist/ui.d.cts +331 -0
- package/dist/ui.d.ts +331 -0
- package/dist/ui.js +749 -0
- package/dist/ui.js.map +1 -0
- package/dist/useSlottedUploader-BzT5jV8c.d.cts +139 -0
- package/dist/useSlottedUploader-IkG5RhEO.d.ts +139 -0
- package/dist/useUploader-BiBdS-7y.d.cts +117 -0
- package/dist/useUploader-CQHpj_oI.d.ts +117 -0
- package/package.json +189 -3
- 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)
|
|
9
|
+
[](https://nodejs.org/)
|
|
10
|
+
[](https://react.dev/)
|
|
11
|
+
[](https://tailwindcss.com/)
|
|
12
|
+
[](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 “Suelta para reemplazar”,
|
|
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
|