@uploadista/react 0.0.3
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/.turbo/turbo-check.log +89 -0
- package/FLOW_UPLOAD.md +307 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/package.json +35 -0
- package/src/components/flow-upload-list.tsx +614 -0
- package/src/components/flow-upload-zone.tsx +441 -0
- package/src/components/upload-list.tsx +626 -0
- package/src/components/upload-zone.tsx +545 -0
- package/src/components/uploadista-provider.tsx +190 -0
- package/src/hooks/use-drag-drop.ts +404 -0
- package/src/hooks/use-flow-upload.ts +568 -0
- package/src/hooks/use-multi-flow-upload.ts +477 -0
- package/src/hooks/use-multi-upload.ts +691 -0
- package/src/hooks/use-upload-metrics.ts +585 -0
- package/src/hooks/use-upload.ts +411 -0
- package/src/hooks/use-uploadista-client.ts +145 -0
- package/src/index.ts +87 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BrowserUploadInput,
|
|
3
|
+
FlowUploadItem,
|
|
4
|
+
MultiFlowUploadOptions,
|
|
5
|
+
MultiFlowUploadState,
|
|
6
|
+
} from "@uploadista/client-browser";
|
|
7
|
+
import type { UploadFile } from "@uploadista/core/types";
|
|
8
|
+
import { useCallback, useRef, useState } from "react";
|
|
9
|
+
import { useUploadistaContext } from "../components/uploadista-provider";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Return value from the useMultiFlowUpload hook with batch upload control methods.
|
|
13
|
+
*
|
|
14
|
+
* @property state - Aggregated state across all flow upload items
|
|
15
|
+
* @property addFiles - Add new files to the upload queue
|
|
16
|
+
* @property removeFile - Remove a file from the queue (aborts if uploading)
|
|
17
|
+
* @property startUpload - Begin uploading all pending files
|
|
18
|
+
* @property abortUpload - Cancel a specific upload by its ID
|
|
19
|
+
* @property abortAll - Cancel all active uploads
|
|
20
|
+
* @property clear - Remove all items and abort active uploads
|
|
21
|
+
* @property retryUpload - Retry a specific failed upload
|
|
22
|
+
* @property isUploading - True when any uploads are in progress
|
|
23
|
+
*/
|
|
24
|
+
export interface UseMultiFlowUploadReturn {
|
|
25
|
+
/**
|
|
26
|
+
* Current upload state
|
|
27
|
+
*/
|
|
28
|
+
state: MultiFlowUploadState<BrowserUploadInput>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Add files to upload queue
|
|
32
|
+
*/
|
|
33
|
+
addFiles: (files: File[] | FileList) => void;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Remove a file from the queue
|
|
37
|
+
*/
|
|
38
|
+
removeFile: (id: string) => void;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Start uploading all pending files
|
|
42
|
+
*/
|
|
43
|
+
startUpload: () => void;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Abort a specific upload by ID
|
|
47
|
+
*/
|
|
48
|
+
abortUpload: (id: string) => void;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Abort all active uploads
|
|
52
|
+
*/
|
|
53
|
+
abortAll: () => void;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Clear all items (aborts any active uploads first)
|
|
57
|
+
*/
|
|
58
|
+
clear: () => void;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Retry a specific failed upload by ID
|
|
62
|
+
*/
|
|
63
|
+
retryUpload: (id: string) => void;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether uploads are in progress
|
|
67
|
+
*/
|
|
68
|
+
isUploading: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* React hook for uploading multiple files through a flow with concurrent upload management.
|
|
73
|
+
* Processes each file through the specified flow while respecting concurrency limits.
|
|
74
|
+
*
|
|
75
|
+
* Each file is uploaded and processed independently through the flow, with automatic
|
|
76
|
+
* queue management. Failed uploads can be retried individually, and uploads can be
|
|
77
|
+
* aborted at any time.
|
|
78
|
+
*
|
|
79
|
+
* Must be used within an UploadistaProvider. Flow events for each upload are automatically
|
|
80
|
+
* tracked and synchronized.
|
|
81
|
+
*
|
|
82
|
+
* @param options - Multi-flow upload configuration including flow config and concurrency settings
|
|
83
|
+
* @returns Multi-flow upload state and control methods
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```tsx
|
|
87
|
+
* // Batch image upload with progress tracking
|
|
88
|
+
* function BatchImageUploader() {
|
|
89
|
+
* const multiFlowUpload = useMultiFlowUpload({
|
|
90
|
+
* flowConfig: {
|
|
91
|
+
* flowId: "image-optimization-flow",
|
|
92
|
+
* storageId: "s3-images",
|
|
93
|
+
* },
|
|
94
|
+
* maxConcurrent: 3, // Process 3 files at a time
|
|
95
|
+
* onItemSuccess: (item) => {
|
|
96
|
+
* console.log(`${item.file.name} uploaded successfully`);
|
|
97
|
+
* },
|
|
98
|
+
* onItemError: (item, error) => {
|
|
99
|
+
* console.error(`${item.file.name} failed:`, error);
|
|
100
|
+
* },
|
|
101
|
+
* onComplete: (items) => {
|
|
102
|
+
* const successful = items.filter(i => i.status === 'success');
|
|
103
|
+
* const failed = items.filter(i => i.status === 'error');
|
|
104
|
+
* console.log(`Batch complete: ${successful.length} successful, ${failed.length} failed`);
|
|
105
|
+
* },
|
|
106
|
+
* });
|
|
107
|
+
*
|
|
108
|
+
* return (
|
|
109
|
+
* <div>
|
|
110
|
+
* <input
|
|
111
|
+
* type="file"
|
|
112
|
+
* multiple
|
|
113
|
+
* accept="image/*"
|
|
114
|
+
* onChange={(e) => {
|
|
115
|
+
* if (e.target.files) {
|
|
116
|
+
* multiFlowUpload.addFiles(e.target.files);
|
|
117
|
+
* multiFlowUpload.startUpload();
|
|
118
|
+
* }
|
|
119
|
+
* }}
|
|
120
|
+
* />
|
|
121
|
+
*
|
|
122
|
+
* <div>
|
|
123
|
+
* <p>Overall Progress: {multiFlowUpload.state.totalProgress}%</p>
|
|
124
|
+
* <p>
|
|
125
|
+
* {multiFlowUpload.state.activeUploads} uploading,
|
|
126
|
+
* {multiFlowUpload.state.completedUploads} completed,
|
|
127
|
+
* {multiFlowUpload.state.failedUploads} failed
|
|
128
|
+
* </p>
|
|
129
|
+
* </div>
|
|
130
|
+
*
|
|
131
|
+
* <div>
|
|
132
|
+
* <button onClick={multiFlowUpload.startUpload} disabled={multiFlowUpload.isUploading}>
|
|
133
|
+
* Start All
|
|
134
|
+
* </button>
|
|
135
|
+
* <button onClick={multiFlowUpload.abortAll} disabled={!multiFlowUpload.isUploading}>
|
|
136
|
+
* Cancel All
|
|
137
|
+
* </button>
|
|
138
|
+
* <button onClick={multiFlowUpload.clear}>
|
|
139
|
+
* Clear List
|
|
140
|
+
* </button>
|
|
141
|
+
* </div>
|
|
142
|
+
*
|
|
143
|
+
* {multiFlowUpload.state.items.map((item) => (
|
|
144
|
+
* <div key={item.id} style={{
|
|
145
|
+
* border: '1px solid #ccc',
|
|
146
|
+
* padding: '1rem',
|
|
147
|
+
* marginBottom: '0.5rem'
|
|
148
|
+
* }}>
|
|
149
|
+
* <div>{item.file instanceof File ? item.file.name : 'File'}</div>
|
|
150
|
+
* <div>Status: {item.status}</div>
|
|
151
|
+
*
|
|
152
|
+
* {item.status === "uploading" && (
|
|
153
|
+
* <div>
|
|
154
|
+
* <progress value={item.progress} max={100} />
|
|
155
|
+
* <span>{item.progress}%</span>
|
|
156
|
+
* <button onClick={() => multiFlowUpload.abortUpload(item.id)}>
|
|
157
|
+
* Cancel
|
|
158
|
+
* </button>
|
|
159
|
+
* </div>
|
|
160
|
+
* )}
|
|
161
|
+
*
|
|
162
|
+
* {item.status === "error" && (
|
|
163
|
+
* <div>
|
|
164
|
+
* <p style={{ color: 'red' }}>{item.error?.message}</p>
|
|
165
|
+
* <button onClick={() => multiFlowUpload.retryUpload(item.id)}>
|
|
166
|
+
* Retry
|
|
167
|
+
* </button>
|
|
168
|
+
* <button onClick={() => multiFlowUpload.removeFile(item.id)}>
|
|
169
|
+
* Remove
|
|
170
|
+
* </button>
|
|
171
|
+
* </div>
|
|
172
|
+
* )}
|
|
173
|
+
*
|
|
174
|
+
* {item.status === "success" && (
|
|
175
|
+
* <div>
|
|
176
|
+
* <p style={{ color: 'green' }}>✓ Upload complete</p>
|
|
177
|
+
* <button onClick={() => multiFlowUpload.removeFile(item.id)}>
|
|
178
|
+
* Remove
|
|
179
|
+
* </button>
|
|
180
|
+
* </div>
|
|
181
|
+
* )}
|
|
182
|
+
* </div>
|
|
183
|
+
* ))}
|
|
184
|
+
* </div>
|
|
185
|
+
* );
|
|
186
|
+
* }
|
|
187
|
+
* ```
|
|
188
|
+
*
|
|
189
|
+
* @see {@link useFlowUpload} for single file flow uploads
|
|
190
|
+
* @see {@link useMultiUpload} for multi-file uploads without flow processing
|
|
191
|
+
*/
|
|
192
|
+
export function useMultiFlowUpload(
|
|
193
|
+
options: MultiFlowUploadOptions<BrowserUploadInput>,
|
|
194
|
+
): UseMultiFlowUploadReturn {
|
|
195
|
+
const client = useUploadistaContext();
|
|
196
|
+
const [items, setItems] = useState<FlowUploadItem<BrowserUploadInput>[]>([]);
|
|
197
|
+
const abortFnsRef = useRef<Map<string, () => void>>(new Map());
|
|
198
|
+
const queueRef = useRef<string[]>([]);
|
|
199
|
+
const activeCountRef = useRef(0);
|
|
200
|
+
|
|
201
|
+
const maxConcurrent = options.maxConcurrent ?? 3;
|
|
202
|
+
|
|
203
|
+
const calculateTotalProgress = useCallback(
|
|
204
|
+
(items: FlowUploadItem<BrowserUploadInput>[]) => {
|
|
205
|
+
if (items.length === 0) return 0;
|
|
206
|
+
const totalProgress = items.reduce((sum, item) => sum + item.progress, 0);
|
|
207
|
+
return Math.round(totalProgress / items.length);
|
|
208
|
+
},
|
|
209
|
+
[],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const processQueue = useCallback(async () => {
|
|
213
|
+
if (
|
|
214
|
+
activeCountRef.current >= maxConcurrent ||
|
|
215
|
+
queueRef.current.length === 0
|
|
216
|
+
) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const itemId = queueRef.current.shift();
|
|
221
|
+
if (!itemId) return;
|
|
222
|
+
|
|
223
|
+
const item = items.find((i) => i.id === itemId);
|
|
224
|
+
if (!item || item.status !== "pending") {
|
|
225
|
+
processQueue();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
activeCountRef.current++;
|
|
230
|
+
|
|
231
|
+
setItems((prev) =>
|
|
232
|
+
prev.map((i) =>
|
|
233
|
+
i.id === itemId ? { ...i, status: "uploading" as const } : i,
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const { abort, jobId } = await client.client.uploadWithFlow(
|
|
239
|
+
item.file,
|
|
240
|
+
options.flowConfig,
|
|
241
|
+
{
|
|
242
|
+
onJobStart: (jobId: string) => {
|
|
243
|
+
setItems((prev) =>
|
|
244
|
+
prev.map((i) => (i.id === itemId ? { ...i, jobId } : i)),
|
|
245
|
+
);
|
|
246
|
+
},
|
|
247
|
+
onProgress: (
|
|
248
|
+
_uploadId: string,
|
|
249
|
+
bytesUploaded: number,
|
|
250
|
+
totalBytes: number | null,
|
|
251
|
+
) => {
|
|
252
|
+
const progress = totalBytes
|
|
253
|
+
? Math.round((bytesUploaded / totalBytes) * 100)
|
|
254
|
+
: 0;
|
|
255
|
+
|
|
256
|
+
setItems((prev) => {
|
|
257
|
+
const updated = prev.map((i) =>
|
|
258
|
+
i.id === itemId
|
|
259
|
+
? {
|
|
260
|
+
...i,
|
|
261
|
+
progress,
|
|
262
|
+
bytesUploaded,
|
|
263
|
+
totalBytes: totalBytes || 0,
|
|
264
|
+
}
|
|
265
|
+
: i,
|
|
266
|
+
);
|
|
267
|
+
const updatedItem = updated.find((i) => i.id === itemId);
|
|
268
|
+
if (updatedItem) {
|
|
269
|
+
options.onItemProgress?.(updatedItem);
|
|
270
|
+
}
|
|
271
|
+
return updated;
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
onSuccess: (result: UploadFile) => {
|
|
275
|
+
setItems((prev) => {
|
|
276
|
+
const updated = prev.map((i) =>
|
|
277
|
+
i.id === itemId
|
|
278
|
+
? { ...i, status: "success" as const, result, progress: 100 }
|
|
279
|
+
: i,
|
|
280
|
+
);
|
|
281
|
+
const updatedItem = updated.find((i) => i.id === itemId);
|
|
282
|
+
if (updatedItem) {
|
|
283
|
+
options.onItemSuccess?.(updatedItem);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Check if all uploads are complete
|
|
287
|
+
const allComplete = updated.every(
|
|
288
|
+
(i) =>
|
|
289
|
+
i.status === "success" ||
|
|
290
|
+
i.status === "error" ||
|
|
291
|
+
i.status === "aborted",
|
|
292
|
+
);
|
|
293
|
+
if (allComplete) {
|
|
294
|
+
options.onComplete?.(updated);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return updated;
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
abortFnsRef.current.delete(itemId);
|
|
301
|
+
activeCountRef.current--;
|
|
302
|
+
processQueue();
|
|
303
|
+
},
|
|
304
|
+
onError: (error: Error) => {
|
|
305
|
+
setItems((prev) => {
|
|
306
|
+
const updated = prev.map((i) =>
|
|
307
|
+
i.id === itemId ? { ...i, status: "error" as const, error } : i,
|
|
308
|
+
);
|
|
309
|
+
const updatedItem = updated.find((i) => i.id === itemId);
|
|
310
|
+
if (updatedItem) {
|
|
311
|
+
options.onItemError?.(updatedItem, error);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Check if all uploads are complete
|
|
315
|
+
const allComplete = updated.every(
|
|
316
|
+
(i) =>
|
|
317
|
+
i.status === "success" ||
|
|
318
|
+
i.status === "error" ||
|
|
319
|
+
i.status === "aborted",
|
|
320
|
+
);
|
|
321
|
+
if (allComplete) {
|
|
322
|
+
options.onComplete?.(updated);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return updated;
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
abortFnsRef.current.delete(itemId);
|
|
329
|
+
activeCountRef.current--;
|
|
330
|
+
processQueue();
|
|
331
|
+
},
|
|
332
|
+
onShouldRetry: options.onShouldRetry,
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
abortFnsRef.current.set(itemId, abort);
|
|
337
|
+
|
|
338
|
+
setItems((prev) =>
|
|
339
|
+
prev.map((i) => (i.id === itemId ? { ...i, jobId } : i)),
|
|
340
|
+
);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
setItems((prev) =>
|
|
343
|
+
prev.map((i) =>
|
|
344
|
+
i.id === itemId
|
|
345
|
+
? { ...i, status: "error" as const, error: error as Error }
|
|
346
|
+
: i,
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
activeCountRef.current--;
|
|
351
|
+
processQueue();
|
|
352
|
+
}
|
|
353
|
+
}, [client, items, maxConcurrent, options]);
|
|
354
|
+
|
|
355
|
+
const addFiles = useCallback((files: File[] | FileList) => {
|
|
356
|
+
const fileArray = Array.from(files);
|
|
357
|
+
const newItems: FlowUploadItem<BrowserUploadInput>[] = fileArray.map(
|
|
358
|
+
(file) => ({
|
|
359
|
+
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
360
|
+
file,
|
|
361
|
+
status: "pending",
|
|
362
|
+
progress: 0,
|
|
363
|
+
bytesUploaded: 0,
|
|
364
|
+
totalBytes: file.size,
|
|
365
|
+
error: null,
|
|
366
|
+
result: null,
|
|
367
|
+
jobId: null,
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
setItems((prev) => [...prev, ...newItems]);
|
|
372
|
+
}, []);
|
|
373
|
+
|
|
374
|
+
const removeFile = useCallback((id: string) => {
|
|
375
|
+
const abortFn = abortFnsRef.current.get(id);
|
|
376
|
+
if (abortFn) {
|
|
377
|
+
abortFn();
|
|
378
|
+
abortFnsRef.current.delete(id);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
setItems((prev) => prev.filter((item) => item.id !== id));
|
|
382
|
+
queueRef.current = queueRef.current.filter((queueId) => queueId !== id);
|
|
383
|
+
}, []);
|
|
384
|
+
|
|
385
|
+
const startUpload = useCallback(() => {
|
|
386
|
+
const pendingItems = items.filter((item) => item.status === "pending");
|
|
387
|
+
queueRef.current.push(...pendingItems.map((item) => item.id));
|
|
388
|
+
|
|
389
|
+
for (let i = 0; i < maxConcurrent; i++) {
|
|
390
|
+
processQueue();
|
|
391
|
+
}
|
|
392
|
+
}, [items, maxConcurrent, processQueue]);
|
|
393
|
+
|
|
394
|
+
const abortUpload = useCallback(
|
|
395
|
+
(id: string) => {
|
|
396
|
+
const abortFn = abortFnsRef.current.get(id);
|
|
397
|
+
if (abortFn) {
|
|
398
|
+
abortFn();
|
|
399
|
+
abortFnsRef.current.delete(id);
|
|
400
|
+
|
|
401
|
+
setItems((prev) =>
|
|
402
|
+
prev.map((item) =>
|
|
403
|
+
item.id === id ? { ...item, status: "aborted" as const } : item,
|
|
404
|
+
),
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
activeCountRef.current--;
|
|
408
|
+
processQueue();
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
[processQueue],
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const abortAll = useCallback(() => {
|
|
415
|
+
for (const abortFn of abortFnsRef.current.values()) {
|
|
416
|
+
abortFn();
|
|
417
|
+
}
|
|
418
|
+
abortFnsRef.current.clear();
|
|
419
|
+
queueRef.current = [];
|
|
420
|
+
activeCountRef.current = 0;
|
|
421
|
+
|
|
422
|
+
setItems((prev) =>
|
|
423
|
+
prev.map((item) =>
|
|
424
|
+
item.status === "uploading"
|
|
425
|
+
? { ...item, status: "aborted" as const }
|
|
426
|
+
: item,
|
|
427
|
+
),
|
|
428
|
+
);
|
|
429
|
+
}, []);
|
|
430
|
+
|
|
431
|
+
const clear = useCallback(() => {
|
|
432
|
+
abortAll();
|
|
433
|
+
setItems([]);
|
|
434
|
+
}, [abortAll]);
|
|
435
|
+
|
|
436
|
+
const retryUpload = useCallback(
|
|
437
|
+
(id: string) => {
|
|
438
|
+
setItems((prev) =>
|
|
439
|
+
prev.map((item) =>
|
|
440
|
+
item.id === id
|
|
441
|
+
? {
|
|
442
|
+
...item,
|
|
443
|
+
status: "pending" as const,
|
|
444
|
+
progress: 0,
|
|
445
|
+
bytesUploaded: 0,
|
|
446
|
+
error: null,
|
|
447
|
+
}
|
|
448
|
+
: item,
|
|
449
|
+
),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
queueRef.current.push(id);
|
|
453
|
+
processQueue();
|
|
454
|
+
},
|
|
455
|
+
[processQueue],
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
const state: MultiFlowUploadState<BrowserUploadInput> = {
|
|
459
|
+
items,
|
|
460
|
+
totalProgress: calculateTotalProgress(items),
|
|
461
|
+
activeUploads: items.filter((item) => item.status === "uploading").length,
|
|
462
|
+
completedUploads: items.filter((item) => item.status === "success").length,
|
|
463
|
+
failedUploads: items.filter((item) => item.status === "error").length,
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
state,
|
|
468
|
+
addFiles,
|
|
469
|
+
removeFile,
|
|
470
|
+
startUpload,
|
|
471
|
+
abortUpload,
|
|
472
|
+
abortAll,
|
|
473
|
+
clear,
|
|
474
|
+
retryUpload,
|
|
475
|
+
isUploading: state.activeUploads > 0,
|
|
476
|
+
};
|
|
477
|
+
}
|