@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.
@@ -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
+ }