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.md
ADDED
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# uploaderkit
|
|
4
|
+
|
|
5
|
+
**File uploads for React and Node, from one shared contract.**
|
|
6
|
+
Scopes both sides validate against, a headless uploader with progress / abort / compression, and a server storage service with pluggable providers.
|
|
7
|
+
|
|
8
|
+
[](./LICENSE)
|
|
9
|
+
[](https://nodejs.org/)
|
|
10
|
+
[](https://react.dev/)
|
|
11
|
+
[](https://tailwindcss.com/)
|
|
12
|
+
[](https://www.typescriptlang.org/)
|
|
13
|
+
|
|
14
|
+
🌐 **English** | [🇲🇽 Español](./README.es.md)
|
|
15
|
+
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Table of Contents
|
|
21
|
+
|
|
22
|
+
- [Features](#features)
|
|
23
|
+
- [Quick Start](#quick-start)
|
|
24
|
+
- [Installation](#installation)
|
|
25
|
+
- [Tailwind v4 Setup](#tailwind-v4-setup)
|
|
26
|
+
- [Scopes — the contract](#scopes--the-contract)
|
|
27
|
+
- [Defining scopes](#defining-scopes)
|
|
28
|
+
- [Replace — never leave a dead file](#replace--never-leave-a-dead-file)
|
|
29
|
+
- [Client](#client)
|
|
30
|
+
- [`useUploader`](#useuploader)
|
|
31
|
+
- [Upload trigger — `select` vs `manual`](#upload-trigger--select-vs-manual)
|
|
32
|
+
- [Retry and concurrency](#retry-and-concurrency)
|
|
33
|
+
- [Renaming on the way in](#renaming-on-the-way-in)
|
|
34
|
+
- [Safe file names](#safe-file-names)
|
|
35
|
+
- [Upload strategies](#upload-strategies)
|
|
36
|
+
- [Validation](#validation)
|
|
37
|
+
- [Image compression](#image-compression)
|
|
38
|
+
- [Named slots (`useSlottedUploader`)](#named-slots-useslotteduploader)
|
|
39
|
+
- [UI components](#ui-components)
|
|
40
|
+
- [`Uploader`](#uploader)
|
|
41
|
+
- [`SlottedUploader`](#slotteduploader)
|
|
42
|
+
- [Confirmations](#confirmations)
|
|
43
|
+
- [File preview (`FileViewer`)](#file-preview-fileviewer)
|
|
44
|
+
- [Reading a stored file](#reading-a-stored-file)
|
|
45
|
+
- [Drop to replace](#drop-to-replace)
|
|
46
|
+
- [Labels — every string is replaceable](#labels--every-string-is-replaceable)
|
|
47
|
+
- [Theming](#theming)
|
|
48
|
+
- [Going fully headless](#going-fully-headless)
|
|
49
|
+
- [Presets](#presets)
|
|
50
|
+
- [Server](#server)
|
|
51
|
+
- [`createStorage`](#createstorage)
|
|
52
|
+
- [Express](#express)
|
|
53
|
+
- [Next.js App Router](#nextjs-app-router)
|
|
54
|
+
- [Encryption](#encryption)
|
|
55
|
+
- [Storage providers](#storage-providers)
|
|
56
|
+
- [Developer feedback](#developer-feedback)
|
|
57
|
+
- [Subpath Exports](#subpath-exports)
|
|
58
|
+
- [License](#license)
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Features
|
|
63
|
+
|
|
64
|
+
- **One contract, both sides** — a scope registry declares where a file lands, who may read it, what is accepted and how big it may be. The browser and the server validate against the same object, so a rejection is never a surprise at the end of a 40 MB upload.
|
|
65
|
+
- **Headless first** — `useUploader` owns selection, validation, compression, per-file progress, abort and error state; it renders nothing. `/ui` is an opt-in skin over it, so an app with its own design system loses no behavior by skipping it.
|
|
66
|
+
- **Client-side validation before the first byte** — extension, size and magic numbers, so an `.exe` renamed to `.pdf` never leaves the machine.
|
|
67
|
+
- **Real upload progress** — the default strategy is XHR because `fetch` still has no usable upload progress in browsers. Every in-flight upload is abortable, per file or all at once.
|
|
68
|
+
- **Image compression in the scope** — declare `compress` and images are downscaled and re-encoded before travelling. EXIF (GPS, camera) drops in the process.
|
|
69
|
+
- **Pluggable transport** — the `UploadStrategy` is a single function `(file, scope, entityId, { onProgress, signal })`. Swap the endpoint, the auth header or the whole protocol without touching hook state.
|
|
70
|
+
- **Server storage service** — `createStorage` re-runs the same validation, encrypts the scopes that ask for it, and answers signed expiring URLs for private objects.
|
|
71
|
+
- **Boot-time guards** — a private scope on a provider that cannot sign, or an encrypted scope without an injected cipher, throws at construction. Deploys fail loudly instead of 500ing on the first upload.
|
|
72
|
+
- **Framework adapters** — structural handlers for Express (the app owns multer) and for the Next.js App Router (native `Request`/`Response`). No framework dependency is pulled in.
|
|
73
|
+
- **Provider adapters** — Google Cloud Storage (two-bucket layout), any S3-compatible backend (AWS, Cloudflare R2, Backblaze B2, MinIO, Wasabi) and an in-memory provider for tests. All optional peers: choosing GCS never installs the AWS SDK.
|
|
74
|
+
- **Named slots** — `SlottedUploader` fills one file per named position (letterhead, ID, tax certificate); a bulk drop routes each file to its slot and renames it so re-uploads overwrite in place.
|
|
75
|
+
- **No cipher shipped by default** — private scopes declare `encrypt: true` and the app injects `CryptoHooks`. `createAesGcmCrypto` is available as a reference implementation.
|
|
76
|
+
- **You choose when the upload fires** — `uploadOn: 'select'` sends as soon as a valid file lands; `'manual'` holds files until `upload()` — the form-submit flow, reachable from the styled components through `controllerRef`. `onUploadStart` announces the moment a batch leaves.
|
|
77
|
+
- **Retry with backoff + concurrency cap** — opt-in resilience for flaky networks: transient strategy failures retry behind exponential backoff, and large batches queue behind a concurrency limit.
|
|
78
|
+
- **Confirmation dialogs built in** — `confirmRemove` / `confirmReplace` gate destructive file actions behind an accessible dialog (focus lands on cancel), and `ConfirmDialog` is exported for app-level use.
|
|
79
|
+
- **Paste and camera capture** — a focused dropzone accepts a pasted screenshot, and `capture` opens the mobile camera directly.
|
|
80
|
+
- **Translatable copy** — every user-facing string flows through a labels object, on the client AND on the server. English by default, `ES_LABELS` included, any language via a partial override.
|
|
81
|
+
- **Rebrandable theme** — the styled layer reads `--color-ui-*` CSS variables so one `:root` override rebrands the whole styled layer.
|
|
82
|
+
- **Sizes, icons and motion** — `size='sm' | 'md'` compacts every row and zone, `icon` swaps (or removes) the dropzone glyph, and the whole surface animates: rows fade in, the drag state scales the zone, overlays enter and exit with a transition, the uploading indicator pulses.
|
|
83
|
+
- **Touch-first by default** — on coarse pointers the zone reads as a tap target ("Toca para elegir un archivo") with press feedback instead of advertising a drag nobody can do; `capture` opens the camera directly.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Quick Start
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pnpm add uploaderkit react react-dom
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// scopes.ts — imported by the client AND the server
|
|
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
|
+
// client
|
|
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
|
+
// server
|
|
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
|
+
## Installation
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
pnpm add uploaderkit
|
|
142
|
+
# peer, only if you use /react or /ui
|
|
143
|
+
pnpm add react react-dom
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Optional peers — install only the provider you use:
|
|
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
|
+
The server entry points depend on nothing but this package: the Express adapter
|
|
154
|
+
matches Express 4 and 5 structurally and the Next.js one uses the Fetch API.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Tailwind v4 Setup
|
|
159
|
+
|
|
160
|
+
Only needed for `/ui`. Register the package's classes once so Tailwind
|
|
161
|
+
generates them:
|
|
162
|
+
|
|
163
|
+
```css
|
|
164
|
+
/* app/globals.css */
|
|
165
|
+
@import 'tailwindcss';
|
|
166
|
+
@import 'uploaderkit/tailwind.css';
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The hooks in `/react` carry no styles, so a headless app skips this entirely.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Scopes — the contract
|
|
174
|
+
|
|
175
|
+
A **scope** is a named destination: the single source of truth for where a file
|
|
176
|
+
lands, who may read it, and what is accepted there. The registry is a plain
|
|
177
|
+
object the client and the server both import, which is what makes the two
|
|
178
|
+
validations agree by construction.
|
|
179
|
+
|
|
180
|
+
### Defining scopes
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { defineScopes, MB } from 'uploaderkit'
|
|
184
|
+
|
|
185
|
+
export const scopes = defineScopes({
|
|
186
|
+
'user-avatar': {
|
|
187
|
+
path: userId => `Users/${userId}/avatar`,
|
|
188
|
+
visibility: 'public',
|
|
189
|
+
accept: ['png', 'jpg', 'jpeg', 'webp'],
|
|
190
|
+
maxBytes: 5 * MB,
|
|
191
|
+
category: 'image',
|
|
192
|
+
compress: { maxWidth: 512, quality: 0.8, stripExif: true },
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
| Field | Meaning |
|
|
198
|
+
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
199
|
+
| `path` | `(entityId, file) => string` — the storage key. You own collisions and folder shape. |
|
|
200
|
+
| `visibility` | `'public'` (direct URL) or `'private'` (signed expiring URL only). |
|
|
201
|
+
| `accept` | Extensions allowed here. Narrower than the category preset, never wider. |
|
|
202
|
+
| `maxBytes` | Hard ceiling. `KB` / `MB` / `GB` helpers are exported. |
|
|
203
|
+
| `category` | `'image' \| 'pdf' \| 'document' \| 'data' \| 'video' \| 'audio' \| 'certificate' \| 'key' \| 'any'`. Picks the preset that decides whether magic numbers are read. |
|
|
204
|
+
| `encrypt` | Hand the bytes to the app's cipher before they leave the server. |
|
|
205
|
+
| `compress` | Client-side image pipeline: `maxWidth`, `maxHeight`, `quality`, `stripExif` (default `true`). |
|
|
206
|
+
| `maxFiles` | How many files one entity may hold here. Default `1`. The uploader derives `multiple` from it. |
|
|
207
|
+
| `replace` | What an upload removes. Derived by default — see [Replace](#replace--never-leave-a-dead-file). |
|
|
208
|
+
| `prefix` | `(entityId) => string` — objects an `'entity'` replace may delete. Defaults to the folder of the resolved key. |
|
|
209
|
+
| `metadata` | Free-form tags forwarded to the provider when it supports them. |
|
|
210
|
+
|
|
211
|
+
### Replace — never leave a dead file
|
|
212
|
+
|
|
213
|
+
Object storage does not clean up after itself. A scope whose key carries the
|
|
214
|
+
file name writes a NEW object every time, so re-uploading a logo leaves the
|
|
215
|
+
previous one paying rent forever. `replace` is what decides that, and its
|
|
216
|
+
default is derived so there is no prop to forget:
|
|
217
|
+
|
|
218
|
+
| The scope | Derived `replace` | Why |
|
|
219
|
+
| ------------------------------------------------ | ----------------- | --------------------------------------------------------------- |
|
|
220
|
+
| `maxFiles: 1` (default), key carries `file.name` | `'entity'` | Every upload lands on a new key — the old object must be swept. |
|
|
221
|
+
| `maxFiles: 1`, key ignores `file.name` | `'key'` | The key is stable, so the provider overwrites in place already. |
|
|
222
|
+
| `maxFiles > 1` | `'key'` | A collection: siblings are the point. |
|
|
223
|
+
|
|
224
|
+
Declare it explicitly only to opt out — `replace: false` keeps every version.
|
|
225
|
+
|
|
226
|
+
The `'entity'` sweep runs **after** a successful put and deletes everything
|
|
227
|
+
under the entity's prefix that is not the new key. Two guards keep it from
|
|
228
|
+
reaching too far, both at `defineScopes` time:
|
|
229
|
+
|
|
230
|
+
- `replace: 'entity'` together with `maxFiles > 1` throws. A scope cannot hold
|
|
231
|
+
a collection and erase it on every upload.
|
|
232
|
+
- Two scopes whose folders overlap throw when either sweeps, so an avatar
|
|
233
|
+
upload can never delete the same user's documents. Give each its own folder,
|
|
234
|
+
or narrow one with `prefix`.
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
defineScopes({
|
|
238
|
+
// One file, swept: uploading `new.png` deletes `old.png`.
|
|
239
|
+
logo: { path: (id, file) => `Companies/${id}/logo/${file.name}` /* … */ },
|
|
240
|
+
|
|
241
|
+
// A collection: `maxFiles` alone switches the semantics.
|
|
242
|
+
expediente: {
|
|
243
|
+
path: (id, file) => `Companies/${id}/docs/${file.name}`,
|
|
244
|
+
maxFiles: 10 /* … */,
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
// Keeps every version on purpose.
|
|
248
|
+
audit: {
|
|
249
|
+
path: (id, file) => `Companies/${id}/audit/${file.name}`,
|
|
250
|
+
replace: false /* … */,
|
|
251
|
+
},
|
|
252
|
+
})
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`defineScopes` returns a `ScopeRegistry`: `names`, `get(name)`, `has(name)` and
|
|
256
|
+
`accept(name)` — the last one being the ready-made string for `<input accept>`.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Client
|
|
261
|
+
|
|
262
|
+
### `useUploader`
|
|
263
|
+
|
|
264
|
+
The headless state machine: selection → validation → (compression) → upload
|
|
265
|
+
with progress and abort. It renders nothing; you render `files` however the
|
|
266
|
+
screen needs.
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
import { useUploader } from 'uploaderkit/react'
|
|
270
|
+
|
|
271
|
+
const { files, accept, addFiles, upload, abort, isUploading, hasPending } =
|
|
272
|
+
useUploader({
|
|
273
|
+
scopes,
|
|
274
|
+
scope: 'invoice-evidence',
|
|
275
|
+
entityId: invoiceId,
|
|
276
|
+
strategy,
|
|
277
|
+
multiple: true,
|
|
278
|
+
maxFiles: 3,
|
|
279
|
+
uploadOn: 'manual', // the default — see "Upload trigger" below
|
|
280
|
+
onUploadStart: files => setSending(true),
|
|
281
|
+
onUploaded: stored => saveToDb(stored),
|
|
282
|
+
onError: message => toast.error(message),
|
|
283
|
+
retry: { attempts: 3, backoffMs: 500 },
|
|
284
|
+
concurrency: 3,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
<input type='file' accept={accept} onChange={e => addFiles(e.target.files!)} />
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Each entry of `files` is an `UploaderFile`:
|
|
291
|
+
|
|
292
|
+
| Field | Meaning |
|
|
293
|
+
| ---------- | --------------------------------------------------------------------- |
|
|
294
|
+
| `id` | Stable id for the row; also the argument to `abort` and `removeFile`. |
|
|
295
|
+
| `file` | The `File` as selected. |
|
|
296
|
+
| `status` | `'idle' \| 'uploading' \| 'success' \| 'error'`. |
|
|
297
|
+
| `progress` | 0–100 while uploading, 100 on success. |
|
|
298
|
+
| `error` | Human message, from validation or the strategy's failure. |
|
|
299
|
+
| `preview` | Object URL for images — a local thumbnail before uploading. |
|
|
300
|
+
| `stored` | The `StoredFile` the server confirmed. |
|
|
301
|
+
|
|
302
|
+
`upload()` sends every file still `idle` and resolves with the confirmed ones.
|
|
303
|
+
`abort(id)` cancels one upload, `abort()` cancels all — an aborted file returns
|
|
304
|
+
to `idle`, not `error`, so the user can retry without clearing anything.
|
|
305
|
+
`hasPending` is `true` while any file waits in `idle` — the flag a submit
|
|
306
|
+
button reads.
|
|
307
|
+
|
|
308
|
+
Omit `strategy` for local-only selection plus validation.
|
|
309
|
+
|
|
310
|
+
### Upload trigger — `select` vs `manual`
|
|
311
|
+
|
|
312
|
+
Not every screen wants the same moment. The contract makes it explicit instead
|
|
313
|
+
of fixing one behavior:
|
|
314
|
+
|
|
315
|
+
- **`uploadOn: 'select'`** — the file travels the moment it validates. The
|
|
316
|
+
drag-and-drop / attach-and-go screens: evidence, avatars, galleries. The
|
|
317
|
+
styled `Uploader` defaults to this.
|
|
318
|
+
- **`uploadOn: 'manual'`** (hook default) — files wait in `idle` until the app
|
|
319
|
+
calls `upload()`. The form flow: every field plus the document commit as one
|
|
320
|
+
action on submit.
|
|
321
|
+
|
|
322
|
+
```tsx
|
|
323
|
+
const uploader = useUploader({ scopes, scope, entityId, strategy }) // manual
|
|
324
|
+
|
|
325
|
+
const onSubmit = async (event: FormEvent) => {
|
|
326
|
+
event.preventDefault()
|
|
327
|
+
if (!form.valid || !uploader.hasPending) return
|
|
328
|
+
const stored = await uploader.upload() // fires here, with the submit
|
|
329
|
+
await saveRecord({ ...form.values, file: stored[0] })
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`onUploadStart(files)` fires when a batch actually leaves — from either
|
|
334
|
+
trigger — so a form can flip into its "sending" state at the true moment, not
|
|
335
|
+
at selection.
|
|
336
|
+
|
|
337
|
+
At the styled-component level the choice is a three-way contract —
|
|
338
|
+
`uploadOn: 'select' | 'submit' | 'manual'` — one mode per kind of screen:
|
|
339
|
+
|
|
340
|
+
| Mode | Who sends | Use it for |
|
|
341
|
+
| ---------- | --------------------------------- | -------------------------------------------------------------------------------- |
|
|
342
|
+
| `'select'` | The zone, the moment a file lands | Avatars, quick replacements — the file IS the action |
|
|
343
|
+
| `'submit'` | The form, via `controllerRef` | Any file that depends on the rest of a form to make sense (documents, catalogs) |
|
|
344
|
+
| `'manual'` | The zone's own upload button | Evidence and punctual flows with no form around them — drop now, send when ready |
|
|
345
|
+
|
|
346
|
+
Prefer `'submit'` whenever the file belongs to a form the user can abandon.
|
|
347
|
+
A scope with a stable key overwrites on every put, so an upload that fires on
|
|
348
|
+
selection has **already** changed what the entity serves — a customer's logo, a
|
|
349
|
+
product photo — even if the operator then hits Cancel. Deferring is what makes
|
|
350
|
+
"cancel" mean cancel. (`SlottedUploader` offers `'select'` and `'submit'` only:
|
|
351
|
+
it has no button surface, so `'manual'` staged slots could never leave.)
|
|
352
|
+
|
|
353
|
+
The `'submit'` wiring:
|
|
354
|
+
|
|
355
|
+
```tsx
|
|
356
|
+
const uploaderRef = useRef<UploaderController | null>(null)
|
|
357
|
+
const [staged, setStaged] = useState(false)
|
|
358
|
+
|
|
359
|
+
const onSubmit = async () => {
|
|
360
|
+
if (uploaderRef.current?.hasPending) await uploaderRef.current.upload()
|
|
361
|
+
await handleSubmit(save)() // reads the values the upload just wrote
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
<Uploader
|
|
365
|
+
{...props}
|
|
366
|
+
uploadOn='submit'
|
|
367
|
+
controllerRef={uploaderRef}
|
|
368
|
+
onPendingChange={setStaged}
|
|
369
|
+
/>
|
|
370
|
+
<button disabled={!isDirty && !staged}>Guardar</button>
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Two details that are easy to get wrong:
|
|
374
|
+
|
|
375
|
+
- Flush **before** `handleSubmit(...)()`, not inside the submit callback. An
|
|
376
|
+
upload settles into the form through `setValue`, and a callback that already
|
|
377
|
+
received its `data` argument would read the values from before it.
|
|
378
|
+
- `onPendingChange` is what tells the form it has unsent work. A staged file
|
|
379
|
+
never touches the fields, so a save button gated on `isDirty` alone stays
|
|
380
|
+
disabled on a pristine form the user just dropped a file into.
|
|
381
|
+
|
|
382
|
+
Under `'submit'` the zone renders no upload button of its own: two ways to
|
|
383
|
+
send the same batch is one too many, and the form's is the one that knows
|
|
384
|
+
whether the rest of the fields are valid.
|
|
385
|
+
|
|
386
|
+
### Retry and concurrency
|
|
387
|
+
|
|
388
|
+
Both opt-in, both living entirely inside the hook:
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
retry: { attempts: 3, backoffMs: 500 }, // or shorthand: retry: 3
|
|
392
|
+
concurrency: 3,
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
- **`retry`** re-runs a failed strategy call before surfacing the error, with
|
|
396
|
+
exponential backoff (`backoffMs`, then ×2 each further attempt). Aborts never
|
|
397
|
+
retry, and validation failures never reach the strategy at all. The row's
|
|
398
|
+
progress resets between attempts; the user only sees an error when the last
|
|
399
|
+
attempt fails.
|
|
400
|
+
- **`concurrency`** caps how many files upload at once; the rest queue. Thirty
|
|
401
|
+
photos on mobile no longer means thirty simultaneous XHRs.
|
|
402
|
+
|
|
403
|
+
### Renaming on the way in
|
|
404
|
+
|
|
405
|
+
`rename` rewrites each file's name before it enters the machine — a folio, a
|
|
406
|
+
client-side input, a slug. It runs **before validation** (a rename that breaks
|
|
407
|
+
the extension is rejected like any invalid file), and the scope's `path`
|
|
408
|
+
reads the new name when it builds the storage key:
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
useUploader({
|
|
412
|
+
scopes,
|
|
413
|
+
scope: 'invoice-evidence',
|
|
414
|
+
entityId,
|
|
415
|
+
strategy,
|
|
416
|
+
rename: file => `${folio}-${file.name}`,
|
|
417
|
+
})
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Named slots already rename to `{slot}.{ext}` — that contract stays theirs.
|
|
421
|
+
|
|
422
|
+
### Safe file names
|
|
423
|
+
|
|
424
|
+
`sanitizeFileName` turns a user's file name into a safe key segment — ASCII,
|
|
425
|
+
lower case, one extension, no path syntax. Call it **inside** your scope's
|
|
426
|
+
`path()`, so client and server derive the same key:
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
path: (id, file) => `Docs/${id}/${sanitizeFileName(file.name)}`
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
The traversal guard behind `resolveKey` judges by path segment, not by
|
|
433
|
+
substring: `Screenshot … 4.18.54 p.m..png` carries `..` without ever being
|
|
434
|
+
traversal, and a macOS screenshot is the common case, not a corner one. The
|
|
435
|
+
guard is the backstop; the sanitizer is the fix.
|
|
436
|
+
|
|
437
|
+
### Upload strategies
|
|
438
|
+
|
|
439
|
+
A strategy is the physical transport for one file. The hook owns state, the
|
|
440
|
+
strategy owns bytes:
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
type UploadStrategy = (
|
|
444
|
+
file: File,
|
|
445
|
+
scope: string,
|
|
446
|
+
entityId: string,
|
|
447
|
+
options: { onProgress: (percent: number) => void; signal: AbortSignal }
|
|
448
|
+
) => Promise<StoredFile>
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
The default one posts multipart to `POST {endpoint}/{scope}/{entityId}/upload`,
|
|
452
|
+
which is exactly what the framework adapters below expose:
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
import { createXhrUploadStrategy } from 'uploaderkit/react'
|
|
456
|
+
|
|
457
|
+
const strategy = createXhrUploadStrategy({
|
|
458
|
+
endpoint: `${apiUrl}/storage`,
|
|
459
|
+
// Evaluated per upload, so a rotating JWT is read at send time.
|
|
460
|
+
headers: () => ({ Authorization: `Bearer ${getToken()}` }),
|
|
461
|
+
fieldName: 'file',
|
|
462
|
+
// Cookie sessions: the api answers on another origin, so the browser drops
|
|
463
|
+
// the session cookie unless the request asks for it.
|
|
464
|
+
credentials: 'include',
|
|
465
|
+
})
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Writing your own is one function — direct-to-bucket signed PUT, a resumable
|
|
469
|
+
protocol, a queue. Aborting must reject with an error named `AbortError`; the
|
|
470
|
+
hook maps that to `idle` instead of `error`.
|
|
471
|
+
|
|
472
|
+
### Validation
|
|
473
|
+
|
|
474
|
+
Runs on the client for feedback and again on the server for safety. Three
|
|
475
|
+
checks, in order: **extension** against the scope's `accept`, **size** against
|
|
476
|
+
`maxBytes`, and **magic numbers** — the leading bytes of the file, so an `.exe`
|
|
477
|
+
renamed to `.pdf` is rejected before it travels.
|
|
478
|
+
|
|
479
|
+
Messages are Spanish and user-safe by design; the failure lands in
|
|
480
|
+
`files[i].error` and in `onError`.
|
|
481
|
+
|
|
482
|
+
### Image compression
|
|
483
|
+
|
|
484
|
+
When the scope declares `compress`, images are downscaled and re-encoded on a
|
|
485
|
+
canvas before the strategy sees them:
|
|
486
|
+
|
|
487
|
+
```ts
|
|
488
|
+
compress: { maxWidth: 512, maxHeight: 512, quality: 0.8, stripExif: true }
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
EXIF is dropped as an inherent side effect of the re-encode — camera photos
|
|
492
|
+
carry GPS coordinates, and a public bucket is the wrong place for them. The
|
|
493
|
+
pipeline falls back to the original file whenever it cannot help, so it never
|
|
494
|
+
fails an upload. `compressImage(file, options)` is exported for one-off use.
|
|
495
|
+
|
|
496
|
+
### Named slots (`useSlottedUploader`)
|
|
497
|
+
|
|
498
|
+
For forms where each position takes exactly one document. This hook wraps
|
|
499
|
+
`useUploader` verbatim: it only decides **which slot** a file fills and renames
|
|
500
|
+
it to `{slot}.{ext}`, so the scope's `path` yields a stable key and a re-upload
|
|
501
|
+
overwrites in place.
|
|
502
|
+
|
|
503
|
+
```tsx
|
|
504
|
+
const { slots, accept, addFiles, addToSlot, removeSlot, abort, isUploading } =
|
|
505
|
+
useSlottedUploader({
|
|
506
|
+
scopes,
|
|
507
|
+
scope: 'company-identity',
|
|
508
|
+
entityId: companyId,
|
|
509
|
+
strategy,
|
|
510
|
+
slots: [
|
|
511
|
+
{ id: 'letterhead', label: 'Letterhead', extensions: ['pdf'] },
|
|
512
|
+
{ id: 'logo', label: 'Logo', extensions: ['png', 'svg'] },
|
|
513
|
+
],
|
|
514
|
+
value: slotFiles, // SlottedFile[] — you own persistence
|
|
515
|
+
onChange: setSlotFiles,
|
|
516
|
+
matchBy: 'extension', // or 'name', or a custom `match` matcher
|
|
517
|
+
})
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Uploads are **controlled**: `value`/`onChange` keep persistence in the caller,
|
|
521
|
+
and the hook merges uploads the parent has not absorbed yet, so two quick drops
|
|
522
|
+
cannot race the controlled state into losing one.
|
|
523
|
+
|
|
524
|
+
`matchBy: 'extension'` (the default) prefers an empty slot, so dropping three
|
|
525
|
+
files fills three positions; `'name'` matches a file named after its slot
|
|
526
|
+
(`letterhead-a4.pdf` → slot `letterhead-a4`). `slotOfStored` recovers the slot
|
|
527
|
+
of a persisted file when you rehydrate from the database.
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
## UI components
|
|
532
|
+
|
|
533
|
+
Both components are skins over the hooks — same validation, compression,
|
|
534
|
+
progress and abort. Import `uploaderkit/tailwind.css` once (see
|
|
535
|
+
[Tailwind v4 Setup](#tailwind-v4-setup)).
|
|
536
|
+
|
|
537
|
+
### `Uploader`
|
|
538
|
+
|
|
539
|
+
One dropzone, one or many files in it.
|
|
540
|
+
|
|
541
|
+
```tsx
|
|
542
|
+
import { Uploader } from 'uploaderkit/ui'
|
|
543
|
+
|
|
544
|
+
;<Uploader
|
|
545
|
+
scopes={scopes}
|
|
546
|
+
scope='invoice-evidence'
|
|
547
|
+
entityId={invoiceId}
|
|
548
|
+
strategy={strategy}
|
|
549
|
+
multiple
|
|
550
|
+
maxFiles={3}
|
|
551
|
+
label='Evidence'
|
|
552
|
+
description='PDF or photo, up to 8 MB'
|
|
553
|
+
stored={saved} // already persisted, rendered on the filesPosition side
|
|
554
|
+
filesPosition='below' // keep the drop target from sliding down the page
|
|
555
|
+
onRemoveStored={forget} // delete from storage here — see Removal below
|
|
556
|
+
confirmRemove // gate it behind a dialog; or { title, message }
|
|
557
|
+
onUploaded={persist}
|
|
558
|
+
resolveViewUrl={file => api.signedUrl(file.key)}
|
|
559
|
+
capture='environment' // mobile: open the rear camera directly
|
|
560
|
+
/>
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
It accepts every `useUploader` option plus the presentation props above, and
|
|
564
|
+
defaults `uploadOn` to `'select'`. `'manual'` gives the zone an upload button
|
|
565
|
+
for the files still waiting; `'submit'` hands the send to your form through
|
|
566
|
+
`controllerRef` (see [Upload trigger](#upload-trigger--select-vs-manual)). `resolveViewUrl` re-signs a
|
|
567
|
+
private object right before previewing it, for the case where the stored URL
|
|
568
|
+
has expired.
|
|
569
|
+
|
|
570
|
+
`filesPosition` decides which side of the dropzone the file lists sit on. It
|
|
571
|
+
defaults to `'above'`, the historical layout; `'below'` keeps the zone anchored,
|
|
572
|
+
which matters when files are added one at a time — otherwise every addition
|
|
573
|
+
pushes the target the user is aiming at further down.
|
|
574
|
+
|
|
575
|
+
`renderFiles` replaces the rows themselves. It receives the persisted files, the
|
|
576
|
+
staged ones with their live status, the default rows already built, and the
|
|
577
|
+
view/remove callbacks — so a screen can render a thumbnail grid, a single
|
|
578
|
+
summary line, or a count folded into its own card:
|
|
579
|
+
|
|
580
|
+
```tsx
|
|
581
|
+
<Uploader
|
|
582
|
+
{...props}
|
|
583
|
+
filesPosition='below'
|
|
584
|
+
renderFiles={({ staged, stored, isEmpty, remove }) =>
|
|
585
|
+
isEmpty ? null : (
|
|
586
|
+
<ul className='grid grid-cols-3 gap-2'>
|
|
587
|
+
{stored.map(file => (
|
|
588
|
+
<li key={file.key}>{file.fileName}</li>
|
|
589
|
+
))}
|
|
590
|
+
{staged.map(file => (
|
|
591
|
+
<li key={file.id} onClick={() => remove(file.id)}>
|
|
592
|
+
{file.file.name} · {file.status}
|
|
593
|
+
</li>
|
|
594
|
+
))}
|
|
595
|
+
</ul>
|
|
596
|
+
)
|
|
597
|
+
}
|
|
598
|
+
/>
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
It changes the rows, not their place: the result still renders on the
|
|
602
|
+
`filesPosition` side. For a layout the zone itself has to be part of — files
|
|
603
|
+
BESIDE the dropzone, everything inside your own frame — skip this skin and
|
|
604
|
+
compose `useUploader` with the exported `Dropzone`, `FileItem` and
|
|
605
|
+
`StoredFileItem`. Nothing here is unavailable there.
|
|
606
|
+
|
|
607
|
+
The dropzone also accepts a **pasted** file while focused (screenshots land as
|
|
608
|
+
uploads), and `capture` makes a touch device offer its camera instead of the
|
|
609
|
+
picker. On a coarse pointer the prompt switches to the tap-first copy
|
|
610
|
+
(`labels.tapPrompt`) with press feedback — a phone user never reads about
|
|
611
|
+
dragging.
|
|
612
|
+
|
|
613
|
+
Presentation knobs: `size='sm'` compacts the zone and every row; `icon`
|
|
614
|
+
replaces the dropzone glyph with any node (`icon={null}` removes it). Colors
|
|
615
|
+
and radii come from the theme variables — see [Theming](#theming).
|
|
616
|
+
|
|
617
|
+
`shortcut='mod+u'` binds a global key (⌘U / Ctrl+U) that opens the picker and
|
|
618
|
+
renders a small `kbd` hint inside the zone, so users discover it. It never
|
|
619
|
+
fires while typing in a field, and two zones claiming the same combo warn in
|
|
620
|
+
development — with several uploaders on screen, give each its own.
|
|
621
|
+
|
|
622
|
+
### `SlottedUploader`
|
|
623
|
+
|
|
624
|
+
A status row per slot plus one bulk dropzone whose matcher routes each file:
|
|
625
|
+
|
|
626
|
+
```tsx
|
|
627
|
+
import { SlottedUploader } from 'uploaderkit/ui'
|
|
628
|
+
|
|
629
|
+
;<SlottedUploader
|
|
630
|
+
scopes={scopes}
|
|
631
|
+
scope='company-identity'
|
|
632
|
+
entityId={companyId}
|
|
633
|
+
strategy={strategy}
|
|
634
|
+
title='Company documents'
|
|
635
|
+
slots={[
|
|
636
|
+
{ id: 'letterhead', label: 'Letterhead', extensions: ['pdf'] },
|
|
637
|
+
{
|
|
638
|
+
id: 'logo',
|
|
639
|
+
label: 'Logo',
|
|
640
|
+
extensions: ['png', 'svg'],
|
|
641
|
+
hint: 'Transparent background',
|
|
642
|
+
},
|
|
643
|
+
]}
|
|
644
|
+
value={slotFiles}
|
|
645
|
+
onChange={setSlotFiles}
|
|
646
|
+
confirmRemove // dialog before forgetting a filled slot
|
|
647
|
+
confirmReplace // dialog naming both files before overwriting
|
|
648
|
+
hideDropzone={false}
|
|
649
|
+
/>
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
`confirmReplace` intercepts the picked file _after_ the pick — the dialog can
|
|
653
|
+
then name what is about to be lost and what replaces it. Both props take
|
|
654
|
+
`true` for the default copy or `{ title, message }` to override it.
|
|
655
|
+
|
|
656
|
+
**Staged rows.** Under `uploadOn: 'submit'` a pick does not travel: it rests on
|
|
657
|
+
its own row with a thumbnail, its name and _ready to upload_, plus Replace and
|
|
658
|
+
Remove, until the form calls `controllerRef.current.upload()`. The status dot
|
|
659
|
+
turns amber to say so. The name shown is the storage one — the file is renamed
|
|
660
|
+
to `{slot}.{ext}` before it enters the machine, which is what the entity will
|
|
661
|
+
actually serve.
|
|
662
|
+
|
|
663
|
+
**Removal deletes, history keeps.** Pass a `removeStrategy` — `createRemoveStrategy({ endpoint, headers, credentials })`, the DELETE mirror of the upload transport (`DELETE {endpoint}/{scope}/{entityId}` with `{ key }`) — and a confirmed removal deletes the object from storage **by itself**, on `Uploader` and `SlottedUploader` alike. `onRemoveStored(stored)` still fires for the app's bookkeeping (clearing the DB reference), delivered BEFORE `onChange`. The reference is forgotten even when the delete fails — a dangling pointer is worse than an orphan — and a refused delete surfaces through `onError`. The opt-out is a scope contract, not a client choice: mark the scope `keepOnRemove: true` and both the components skip the delete and the server's `storage.remove` answers `false` — history enforced where no client can bypass it.
|
|
664
|
+
|
|
665
|
+
**Language.** English is the default across the kit. A Spanish app opts in once at the root — `<UploaderProvider language='es'>` (exported from `/react` and `/ui`) — and every component and hook under it, including validation messages like `maxFilesReached`, speaks Spanish; a per-component `labels` prop still wins for one-off rewording.
|
|
666
|
+
|
|
667
|
+
### Confirmations
|
|
668
|
+
|
|
669
|
+
Destructive file actions get a second step: an accessible dialog (portal,
|
|
670
|
+
focus trapped, **focus lands on cancel** so a stray Enter never destroys
|
|
671
|
+
anything, Escape and the backdrop cancel). `ConfirmDialog` is exported for
|
|
672
|
+
wrapping your own actions in the same UX:
|
|
673
|
+
|
|
674
|
+
```tsx
|
|
675
|
+
import { ConfirmDialog } from 'uploaderkit/ui'
|
|
676
|
+
|
|
677
|
+
;<ConfirmDialog
|
|
678
|
+
open={confirming}
|
|
679
|
+
title='Eliminar expediente'
|
|
680
|
+
message='Se borrarán también sus documentos.'
|
|
681
|
+
variant='danger'
|
|
682
|
+
onConfirm={destroy}
|
|
683
|
+
onCancel={() => setConfirming(false)}
|
|
684
|
+
/>
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
### File preview (`FileViewer`)
|
|
688
|
+
|
|
689
|
+
Both uploaders embed the full-screen viewer; it is exported standalone for any
|
|
690
|
+
screen that persists a `StoredFile`. It portals to `<body>` (no ancestor
|
|
691
|
+
stacking context can trap it), traps focus while open and restores it on
|
|
692
|
+
close, and locks the page scroll behind it. When `resolveUrl` fails — an
|
|
693
|
+
expired signature, a dropped connection — the viewer shows a retryable error
|
|
694
|
+
instead of loading forever. On narrow viewports a PDF renders as a download
|
|
695
|
+
card instead of an embedded frame (iOS Safari freezes embedded PDFs).
|
|
696
|
+
|
|
697
|
+
Pass `files` (the collection) alongside `file` (the one clicked) and the
|
|
698
|
+
viewer becomes a gallery: side arrows, `←`/`→` on the keyboard, a `2 / 5`
|
|
699
|
+
counter, and — on fine pointers — a footer hinting the shortcuts (`Esc`,
|
|
700
|
+
`←` `→`), so nobody has to guess them:
|
|
701
|
+
|
|
702
|
+
```tsx
|
|
703
|
+
<FileViewer file={viewing} files={storedImages} onClose={close} />
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
Keyboard: `Esc` closes, `←`/`→` walk the gallery, `D` downloads and `O` opens
|
|
707
|
+
the file in a tab — each hinted in the footer on fine pointers. A modifier is
|
|
708
|
+
never claimed, so `⌘D` still bookmarks.
|
|
709
|
+
|
|
710
|
+
`renderError` replaces the built-in "could not load" panel. It receives the
|
|
711
|
+
file that failed plus a `retry` that re-resolves it, so a custom panel keeps
|
|
712
|
+
the recovery the default one offers:
|
|
713
|
+
|
|
714
|
+
```tsx
|
|
715
|
+
<FileViewer
|
|
716
|
+
file={viewing}
|
|
717
|
+
onClose={close}
|
|
718
|
+
renderError={({ file, retry }) => (
|
|
719
|
+
<MyErrorState name={file.fileName} onRetry={retry} />
|
|
720
|
+
)}
|
|
721
|
+
/>
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
`useFileViewer` owns the open/close state every screen would otherwise repeat:
|
|
725
|
+
|
|
726
|
+
```tsx
|
|
727
|
+
const viewer = useFileViewer({ resolveUrl })
|
|
728
|
+
|
|
729
|
+
<button onClick={() => viewer.open(stored)}>Ver</button>
|
|
730
|
+
<FileViewer {...viewer.viewerProps} />
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
### Reading a stored file
|
|
734
|
+
|
|
735
|
+
An `<img>` or an `<iframe>` cannot send an `Authorization` header, so a
|
|
736
|
+
private or encrypted object never renders from its raw url. Two helpers do
|
|
737
|
+
the authenticated read for you — same rule, two output shapes:
|
|
738
|
+
|
|
739
|
+
```tsx
|
|
740
|
+
import {
|
|
741
|
+
createBlobUrlResolver,
|
|
742
|
+
createBytesResolver,
|
|
743
|
+
} from 'uploaderkit/react'
|
|
744
|
+
|
|
745
|
+
// For the viewer: fetches with the app's headers, hands back an object URL.
|
|
746
|
+
const resolveViewUrl = createBlobUrlResolver({
|
|
747
|
+
baseUrl: apiUrl,
|
|
748
|
+
headers: () => ({ Authorization: `Bearer ${getToken()}` }),
|
|
749
|
+
credentials: 'include',
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
<Uploader {...props} resolveViewUrl={resolveViewUrl} />
|
|
753
|
+
|
|
754
|
+
// For code that processes the file instead of showing it.
|
|
755
|
+
const readBytes = createBytesResolver({ baseUrl: apiUrl, headers })
|
|
756
|
+
const pdf = await PDFDocument.load(await readBytes(stored.url))
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
The rule both share is about **origin, not shape**: a url `baseUrl` serves —
|
|
760
|
+
app-relative, or absolute on the same origin — is yours and travels with your
|
|
761
|
+
headers and `credentials`; anything on a **foreign** origin is already
|
|
762
|
+
reachable and is fetched bare, because the token must never go to a
|
|
763
|
+
third-party host. Your server's `encryptedUrl` usually persists an absolute
|
|
764
|
+
url pointing back at your own `/view` route: that one counts as yours. Reading bytes through your own
|
|
765
|
+
endpoint is also what spares a public bucket its own CORS policy: an `<img>`
|
|
766
|
+
is exempt from CORS, a `fetch` for bytes is not.
|
|
767
|
+
|
|
768
|
+
`viewUrlFileName(url)` recovers the display name from a `/view?key=…` url.
|
|
769
|
+
|
|
770
|
+
### Drop to replace
|
|
771
|
+
|
|
772
|
+
A filled `SlottedUploader` row is itself a drop target: dragging a file over
|
|
773
|
+
it lights the row up with a "Drop to replace" pill, and the drop runs through
|
|
774
|
+
the same `confirmReplace` dialog as the button path. An empty row accepts the
|
|
775
|
+
drop as a direct fill — no trip through the bulk zone's matcher.
|
|
776
|
+
|
|
777
|
+
### Labels — every string is replaceable
|
|
778
|
+
|
|
779
|
+
All user-facing copy flows through one object, `UploaderLabels`. English is the
|
|
780
|
+
default; Spanish ships as `ES_LABELS`. Select a language once at the root:
|
|
781
|
+
|
|
782
|
+
```tsx
|
|
783
|
+
import { UploaderProvider } from 'uploaderkit/react'
|
|
784
|
+
|
|
785
|
+
;<UploaderProvider language='es'>
|
|
786
|
+
<App />
|
|
787
|
+
</UploaderProvider>
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
Any partial override wins over that base, per component or per hook:
|
|
791
|
+
|
|
792
|
+
```tsx
|
|
793
|
+
<Uploader {...props} labels={{ dropPrompt: 'Drop your invoice here' }} />
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
#### The copy reaches further than the components
|
|
797
|
+
|
|
798
|
+
The object is not only for markup — it words the validation messages and the
|
|
799
|
+
server's HTTP responses too, which is what keeps one rejection from arriving in
|
|
800
|
+
two languages:
|
|
801
|
+
|
|
802
|
+
```ts
|
|
803
|
+
import { ES_LABELS, validateForScope } from 'uploaderkit'
|
|
804
|
+
|
|
805
|
+
const result = await validateForScope(scopes, 'invoices', file, ES_LABELS)
|
|
806
|
+
// result.message is Spanish
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
| Surface | How it takes the copy |
|
|
810
|
+
| --------------------------------------------- | ---------------------------------- |
|
|
811
|
+
| `Uploader`, `SlottedUploader`, presets | `labels` prop, over the provider |
|
|
812
|
+
| `useUploader`, `useSlottedUploader` | `labels` option, over the provider |
|
|
813
|
+
| `validateFile`, `validateFiles` | `labels` in `ValidationOptions` |
|
|
814
|
+
| `validateForScope` | 4th argument |
|
|
815
|
+
| `createXhrUploadStrategy`, the view resolvers | `labels` option |
|
|
816
|
+
| `createStorage` — and both framework adapters | `labels` option, once |
|
|
817
|
+
|
|
818
|
+
On the server, one option covers the whole round trip: `createStorage` resolves
|
|
819
|
+
the copy and republishes it as `storage.labels`, which is exactly where
|
|
820
|
+
`createExpressStorageHandlers` and `createNextStorageHandlers` read from, so a
|
|
821
|
+
route can never answer in a different language than the service behind it.
|
|
822
|
+
|
|
823
|
+
```ts
|
|
824
|
+
import { ES_LABELS } from 'uploaderkit'
|
|
825
|
+
|
|
826
|
+
const storage = createStorage({ scopes, provider, labels: ES_LABELS })
|
|
827
|
+
// 401 → "No autorizado"; a rejected upload → the same 422 text the browser showed
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
`ScopeError` is the one exception, deliberately: it flags a wiring bug, stays
|
|
831
|
+
English, and is never serialized to a client.
|
|
832
|
+
|
|
833
|
+
See `UploaderLabels` for the full key list.
|
|
834
|
+
|
|
835
|
+
### Theming
|
|
836
|
+
|
|
837
|
+
The styled layer reads `--color-ui-*` / `--radius-ui*` CSS variables, declared
|
|
838
|
+
with defaults in `tailwind.css`. An app rebrands the whole styled layer with
|
|
839
|
+
a single 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
|
+
The override scopes like any CSS variable: put it on a wrapper `div` to
|
|
850
|
+
re-brand a single uploader instead of the whole app.
|
|
851
|
+
|
|
852
|
+
Motion ships with the components: rows animate in
|
|
853
|
+
(`--animate-ui-fade-in`), the viewer and the confirm dialog fade/scale on
|
|
854
|
+
enter and exit, the drag state lifts the zone and a press compresses it, and
|
|
855
|
+
the slot indicator pulses while uploading. All CSS — nothing to configure,
|
|
856
|
+
`prefers-reduced-motion` friendly to override from the app.
|
|
857
|
+
|
|
858
|
+
`Dropzone`, `FileItem` and `FileViewer` are exported separately for building a
|
|
859
|
+
different arrangement out of the same pieces.
|
|
860
|
+
|
|
861
|
+
### Going fully headless
|
|
862
|
+
|
|
863
|
+
An app with its own design system uses `/react` directly and loses nothing —
|
|
864
|
+
validation, compression, progress, abort and slot routing all live in the
|
|
865
|
+
hooks. `/ui` exists so a screen that does not need custom markup does not have
|
|
866
|
+
to write any.
|
|
867
|
+
|
|
868
|
+
---
|
|
869
|
+
|
|
870
|
+
## Presets
|
|
871
|
+
|
|
872
|
+
The recipes the playground kept demonstrating are components now, under `uploaderkit/presets`. Each composes `./react` and `./ui`, reads the same `--color-ui-*` tokens and the same labels, and stays optional: when one does not fit, `useUploader` + `Dropzone` + `FileItem` are still the floor.
|
|
873
|
+
|
|
874
|
+
| Preset | What it is |
|
|
875
|
+
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
876
|
+
| `useDropAnywhere` + `DropAnywhereOverlay` | The whole window as a drop target. The hook counts `dragenter`/`dragleave` depth at window level and is the **only** `drop` handler, so a file never arrives twice; the overlay is purely visual (`pointer-events-none`). `accept` uses `<input>` syntax. |
|
|
877
|
+
| `AvatarUploader` | A picture that is its own control: click or drop on it, a progress ring closes around it, `onUploaded` hands back the `StoredFile`. Uploads on select on purpose. |
|
|
878
|
+
| `GalleryUploader` | A thumbnail grid: persisted `stored` tiles first, in-flight files with a progress wash, hover view/remove, the add tile as the dropzone, and `FileViewer` over the whole set. |
|
|
879
|
+
|
|
880
|
+
```tsx
|
|
881
|
+
const uploader = useUploader({ scopes, scope: 'attachments', entityId, strategy, multiple: true })
|
|
882
|
+
const { dragging } = useDropAnywhere({ onFiles: files => uploader.addFiles(files), accept: uploader.accept })
|
|
883
|
+
|
|
884
|
+
<DropAnywhereOverlay open={dragging} />
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
## Server
|
|
888
|
+
|
|
889
|
+
### `createStorage`
|
|
890
|
+
|
|
891
|
+
The server side of the contract: it re-runs the same validation the browser
|
|
892
|
+
ran, encrypts what the scope declares, and talks to a `StorageProvider`.
|
|
893
|
+
|
|
894
|
+
```ts
|
|
895
|
+
import { createStorage } from 'uploaderkit/server'
|
|
896
|
+
import { createGcsProvider } from 'uploaderkit/adapters/gcs'
|
|
897
|
+
|
|
898
|
+
const storage = createStorage({
|
|
899
|
+
scopes,
|
|
900
|
+
provider: createGcsProvider({ publicBucket, privateBucket }),
|
|
901
|
+
crypto: { encrypt, decrypt }, // required when any scope declares `encrypt`
|
|
902
|
+
signedUrlTtl: 300, // seconds
|
|
903
|
+
})
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
| Method | Answers |
|
|
907
|
+
| ------------------------------------------------ | ---------------------------------------------------------------- |
|
|
908
|
+
| `upload({ scope, entityId, file, uploadedBy })` | An `UploadResult`: the `StoredFile` to persist, plus `replaced`. |
|
|
909
|
+
| `read({ scope, key })` | Raw bytes, decrypted when the scope is encrypted. |
|
|
910
|
+
| `remove({ scope, key })` | `true` when the object existed. |
|
|
911
|
+
| `signedUrl({ scope, key, download, expiresIn })` | A fresh expiring URL. Throws on public scopes. |
|
|
912
|
+
| `list(prefix)` | `{ key, size }[]`. |
|
|
913
|
+
|
|
914
|
+
Construction is defensive: a private scope riding a provider that cannot sign,
|
|
915
|
+
or an encrypted scope without `crypto`, throws a `ScopeError` **before the
|
|
916
|
+
first request** — while a deploy can still fail loudly.
|
|
917
|
+
|
|
918
|
+
#### What an upload replaced
|
|
919
|
+
|
|
920
|
+
`upload()` answers an `UploadResult` — a `StoredFile` plus the keys the sweep
|
|
921
|
+
removed:
|
|
922
|
+
|
|
923
|
+
```ts
|
|
924
|
+
const { key, url, replaced } = await storage.upload({ scope, entityId, file })
|
|
925
|
+
|
|
926
|
+
// The bucket no longer has these. Whatever you persisted must forget them too,
|
|
927
|
+
// or your UI keeps rendering objects that are gone.
|
|
928
|
+
await db.files.deleteMany({ key: { $in: replaced } })
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
`replaced` is empty unless the scope resolves to an `'entity'` replace, and it
|
|
932
|
+
only lists what the provider confirmed deleted. The sweep runs **after** a
|
|
933
|
+
successful put — a failure between the two would otherwise leave the entity
|
|
934
|
+
with nothing — and a delete that fails is swallowed: the upload the caller
|
|
935
|
+
asked for did happen, and a stale object is not worth failing it over.
|
|
936
|
+
|
|
937
|
+
A public object's `url` carries a short `?v=` fingerprint of its content, so a
|
|
938
|
+
stable-key scope (an avatar) does not keep serving the previous image from a
|
|
939
|
+
CDN or the browser cache after an overwrite.
|
|
940
|
+
|
|
941
|
+
#### Streaming reads
|
|
942
|
+
|
|
943
|
+
`storage.readStream({ scope, key })` serves a file without holding it in
|
|
944
|
+
memory — through `get` a 20MB document costs its full size in RAM per
|
|
945
|
+
concurrent reader. The Express `view` handler pipes it. It degrades honestly:
|
|
946
|
+
a provider without `getStream`, or an encrypted scope whose crypto lacks
|
|
947
|
+
`decryptStream`, falls back to a buffered read wrapped in a one-chunk stream,
|
|
948
|
+
so callers always get one shape.
|
|
949
|
+
|
|
950
|
+
The trade of streaming decryption, stated where you decide: plaintext reaches
|
|
951
|
+
the consumer **before** the GCM tag is verified, so tampering surfaces as a
|
|
952
|
+
stream that breaks at the end — `read()` verifies before returning a single
|
|
953
|
+
byte.
|
|
954
|
+
|
|
955
|
+
### Express
|
|
956
|
+
|
|
957
|
+
Structural request/response shapes instead of Express types, so the package
|
|
958
|
+
stays dependency-free and any Express 4/5 app satisfies them. The app keeps
|
|
959
|
+
ownership of multer:
|
|
960
|
+
|
|
961
|
+
```ts
|
|
962
|
+
import { createExpressStorageHandlers } from 'uploaderkit/server/express'
|
|
963
|
+
|
|
964
|
+
const handlers = createExpressStorageHandlers(storage, {
|
|
965
|
+
authorize: async req => (req.user ? { userId: req.user.id } : null),
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
const upload = multer({ storage: multer.memoryStorage() })
|
|
969
|
+
router.post(
|
|
970
|
+
'/:scope/:entityId/upload',
|
|
971
|
+
useAuth,
|
|
972
|
+
upload.single('file'),
|
|
973
|
+
handlers.upload
|
|
974
|
+
)
|
|
975
|
+
router.delete('/:scope/:entityId', useAuth, handlers.remove)
|
|
976
|
+
router.get('/:scope/:entityId/signed-url', useAuth, handlers.signedUrl)
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
`authorize` returns the acting user (or `{}` for "allowed") to proceed, or
|
|
980
|
+
`null` to answer 401.
|
|
981
|
+
|
|
982
|
+
### Next.js App Router
|
|
983
|
+
|
|
984
|
+
Same surface over the Fetch API:
|
|
985
|
+
|
|
986
|
+
```ts
|
|
987
|
+
// app/api/storage/[scope]/[entityId]/upload/route.ts
|
|
988
|
+
import { createNextStorageHandlers } from 'uploaderkit/server/next'
|
|
989
|
+
|
|
990
|
+
const handlers = createNextStorageHandlers(storage, {
|
|
991
|
+
authorize: async request => {
|
|
992
|
+
const session = await auth(request)
|
|
993
|
+
return session ? { userId: session.userId } : null
|
|
994
|
+
},
|
|
995
|
+
})
|
|
996
|
+
|
|
997
|
+
export const POST = handlers.upload
|
|
998
|
+
```
|
|
999
|
+
|
|
1000
|
+
Omitting `authorize` leaves the router **open** — only acceptable behind an
|
|
1001
|
+
authenticated proxy.
|
|
1002
|
+
|
|
1003
|
+
### Encryption
|
|
1004
|
+
|
|
1005
|
+
An encrypted scope needs **two** things wired, and `createStorage` throws at
|
|
1006
|
+
boot without either: the cipher, and `encryptedUrl`.
|
|
1007
|
+
|
|
1008
|
+
```ts
|
|
1009
|
+
const storage = createStorage({
|
|
1010
|
+
scopes,
|
|
1011
|
+
provider,
|
|
1012
|
+
crypto,
|
|
1013
|
+
// Where a client can READ an encrypted object. The bucket holds
|
|
1014
|
+
// ciphertext, so a signed URL would serve garbage — this must point at
|
|
1015
|
+
// your authenticated view route, which decrypts on the way out.
|
|
1016
|
+
encryptedUrl: ({ scope, entityId, key }) =>
|
|
1017
|
+
`/api/storage/${scope}/${entityId}/view?key=${encodeURIComponent(key)}`,
|
|
1018
|
+
})
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
`StoredFile.url` for those scopes is that route, so an `<img>` or the
|
|
1022
|
+
`FileViewer` renders the real file. Both framework adapters expose the route
|
|
1023
|
+
as `handlers.view`, answering the decrypted bytes with
|
|
1024
|
+
`Cache-Control: private, no-store` — decrypted content must never land in a
|
|
1025
|
+
shared cache:
|
|
1026
|
+
|
|
1027
|
+
```ts
|
|
1028
|
+
// Express
|
|
1029
|
+
router.get('/:scope/:entityId/view', useAuth, handlers.view)
|
|
1030
|
+
|
|
1031
|
+
// Next App Router — app/api/storage/[scope]/[entityId]/view/route.ts
|
|
1032
|
+
export const GET = handlers.view
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
No cipher is imposed: a scope declares `encrypt: true` and the app injects the
|
|
1036
|
+
`CryptoHooks`. `createAesGcmCrypto` is the reference implementation
|
|
1037
|
+
(AES-256-GCM, layout `[iv 12][tag 16][ciphertext]`) so you do not hand-roll it:
|
|
1038
|
+
|
|
1039
|
+
```ts
|
|
1040
|
+
import { createAesGcmCrypto } from 'uploaderkit/server'
|
|
1041
|
+
|
|
1042
|
+
const crypto = createAesGcmCrypto(process.env.STORAGE_KEY!) // openssl rand -hex 32
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
The key must be exactly 64 hex characters (32 bytes) — no passphrase
|
|
1046
|
+
derivation on purpose, since deriving would let two instances run "almost the
|
|
1047
|
+
same" secret and silently produce mutually unreadable files.
|
|
1048
|
+
|
|
1049
|
+
Encrypted objects are stored as `application/octet-stream`, so nothing ever
|
|
1050
|
+
tries to render ciphertext; `read()` decrypts on the way out.
|
|
1051
|
+
|
|
1052
|
+
---
|
|
1053
|
+
|
|
1054
|
+
## Storage providers
|
|
1055
|
+
|
|
1056
|
+
A provider is the thin translation to a bucket. All three implement the same
|
|
1057
|
+
contract, so swapping one never touches the scopes or the service.
|
|
1058
|
+
|
|
1059
|
+
```ts
|
|
1060
|
+
// Google Cloud Storage — two buckets: public assets, private documents
|
|
1061
|
+
import { createGcsProvider } from 'uploaderkit/adapters/gcs'
|
|
1062
|
+
|
|
1063
|
+
const provider = createGcsProvider({
|
|
1064
|
+
publicBucket,
|
|
1065
|
+
privateBucket,
|
|
1066
|
+
publicUrl: (bucket, key) => `https://cdn.example.com/${key}`,
|
|
1067
|
+
})
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
```ts
|
|
1071
|
+
// S3-compatible — AWS, Cloudflare R2, Backblaze B2, MinIO, Wasabi
|
|
1072
|
+
import { createS3Provider } from 'uploaderkit/adapters/s3'
|
|
1073
|
+
|
|
1074
|
+
const provider = createS3Provider({
|
|
1075
|
+
client, // an @aws-sdk/client-s3 S3Client; only endpoint/credentials differ per backend
|
|
1076
|
+
bucket: 'uploads',
|
|
1077
|
+
publicUrl: key => `https://cdn.example.com/${key}`,
|
|
1078
|
+
})
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
```ts
|
|
1082
|
+
// Tests and local development
|
|
1083
|
+
import { createMemoryProvider } from 'uploaderkit/adapters/memory'
|
|
1084
|
+
|
|
1085
|
+
const provider = createMemoryProvider() // signed URLs are fake but carry the expiry
|
|
1086
|
+
```
|
|
1087
|
+
|
|
1088
|
+
The S3 bucket is treated as private and `publicUrl` maps the keys a CDN or
|
|
1089
|
+
public domain exposes — modern buckets block per-object ACLs, so the adapter
|
|
1090
|
+
cannot invent a stable public URL on its own. Both SDKs are optional peers:
|
|
1091
|
+
importing an adapter without its SDK installed fails only for the app that
|
|
1092
|
+
chose that backend.
|
|
1093
|
+
|
|
1094
|
+
---
|
|
1095
|
+
|
|
1096
|
+
## Developer feedback
|
|
1097
|
+
|
|
1098
|
+
Two tiers, so a broken config surfaces where it can still be fixed:
|
|
1099
|
+
|
|
1100
|
+
- **`ScopeError` — thrown, eagerly.** Configs that can never work: a malformed
|
|
1101
|
+
scope, an unknown scope name, duplicate slot ids, a private scope on a
|
|
1102
|
+
non-signing provider, an encrypted scope without `CryptoHooks`. They fail at
|
|
1103
|
+
import or boot, never in front of a user. The framework adapters rethrow it
|
|
1104
|
+
instead of serializing it to the client.
|
|
1105
|
+
- **Development warnings — once per issue.** Configs that run but probably are
|
|
1106
|
+
not what you meant: calling `upload()` without a strategy, dropping several
|
|
1107
|
+
files in single mode, a slot accepting extensions its scope rejects. Prefixed
|
|
1108
|
+
`[uploaderkit]`, silent in production, and never load-bearing for behavior.
|
|
1109
|
+
|
|
1110
|
+
Request-level failures are a third, separate family: `StorageRequestError`
|
|
1111
|
+
carries an HTTP status and a Spanish user-safe message, which the framework
|
|
1112
|
+
adapters map to JSON.
|
|
1113
|
+
|
|
1114
|
+
---
|
|
1115
|
+
|
|
1116
|
+
## Subpath Exports
|
|
1117
|
+
|
|
1118
|
+
| Import path | Contents |
|
|
1119
|
+
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1120
|
+
| `uploaderkit` | `defineScopes`, `validateForScope`, `resolveKey`, `toAcceptAttribute`, `formatFileSize`, `KB`/`MB`/`GB`, `ScopeError`, `DEFAULT_LABELS`/`ES_LABELS`, types |
|
|
1121
|
+
| `uploaderkit/react` | `useUploader`, `useSlottedUploader`, `createXhrUploadStrategy`, `compressImage`, slot matchers, types |
|
|
1122
|
+
| `uploaderkit/ui` | `Uploader`, `SlottedUploader`, `Dropzone`, `FileItem`, `FileViewer`, `ConfirmDialog`, `cn` |
|
|
1123
|
+
| `uploaderkit/server` | `createStorage`, `createAesGcmCrypto`, `StorageRequestError`, types |
|
|
1124
|
+
| `uploaderkit/server/express` | `createExpressStorageHandlers` — structural handlers for an Express app that owns multer |
|
|
1125
|
+
| `uploaderkit/server/next` | `createNextStorageHandlers` — App Router handlers over the Fetch API |
|
|
1126
|
+
| `uploaderkit/adapters/gcs` | `createGcsProvider` — two-bucket Google Cloud Storage (optional peer) |
|
|
1127
|
+
| `uploaderkit/adapters/s3` | `createS3Provider` — AWS, R2, B2, MinIO, Wasabi (optional peers) |
|
|
1128
|
+
| `uploaderkit/adapters/memory` | `createMemoryProvider` — tests and local development |
|
|
1129
|
+
| `uploaderkit/tailwind.css` | Tailwind v4 source registration for the `/ui` classes |
|
|
1130
|
+
|
|
1131
|
+
---
|
|
1132
|
+
|
|
1133
|
+
## License
|
|
1134
|
+
|
|
1135
|
+
MIT © Ricardo Tapia
|