@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,691 @@
1
+ import type { BrowserUploadInput } from "@uploadista/client-browser";
2
+ import type { UploadFile } from "@uploadista/core/types";
3
+ import { useCallback, useRef, useState } from "react";
4
+ import { useUploadistaContext } from "../components/uploadista-provider";
5
+ import type {
6
+ UploadMetrics,
7
+ UploadState,
8
+ UploadStatus,
9
+ UseUploadOptions,
10
+ } from "./use-upload";
11
+
12
+ export interface UploadItem {
13
+ id: string;
14
+ file: BrowserUploadInput;
15
+ state: UploadState;
16
+ }
17
+
18
+ export interface MultiUploadOptions
19
+ extends Omit<UseUploadOptions, "onSuccess" | "onError" | "onProgress"> {
20
+ /**
21
+ * Maximum number of concurrent uploads
22
+ */
23
+ maxConcurrent?: number;
24
+
25
+ /**
26
+ * Called when an individual file upload starts
27
+ */
28
+ onUploadStart?: (item: UploadItem) => void;
29
+
30
+ /**
31
+ * Called when an individual file upload progresses
32
+ */
33
+ onUploadProgress?: (
34
+ item: UploadItem,
35
+ progress: number,
36
+ bytesUploaded: number,
37
+ totalBytes: number | null,
38
+ ) => void;
39
+
40
+ /**
41
+ * Called when an individual file upload succeeds
42
+ */
43
+ onUploadSuccess?: (item: UploadItem, result: UploadFile) => void;
44
+
45
+ /**
46
+ * Called when an individual file upload fails
47
+ */
48
+ onUploadError?: (item: UploadItem, error: Error) => void;
49
+
50
+ /**
51
+ * Called when all uploads complete (successfully or with errors)
52
+ */
53
+ onComplete?: (results: {
54
+ successful: UploadItem[];
55
+ failed: UploadItem[];
56
+ total: number;
57
+ }) => void;
58
+ }
59
+
60
+ export interface MultiUploadState {
61
+ /**
62
+ * Total number of uploads
63
+ */
64
+ total: number;
65
+
66
+ /**
67
+ * Number of completed uploads (successful + failed)
68
+ */
69
+ completed: number;
70
+
71
+ /**
72
+ * Number of successful uploads
73
+ */
74
+ successful: number;
75
+
76
+ /**
77
+ * Number of failed uploads
78
+ */
79
+ failed: number;
80
+
81
+ /**
82
+ * Number of currently uploading files
83
+ */
84
+ uploading: number;
85
+
86
+ /**
87
+ * Overall progress as a percentage (0-100)
88
+ */
89
+ progress: number;
90
+
91
+ /**
92
+ * Total bytes uploaded across all files
93
+ */
94
+ totalBytesUploaded: number;
95
+
96
+ /**
97
+ * Total bytes to upload across all files
98
+ */
99
+ totalBytes: number;
100
+
101
+ /**
102
+ * Whether any uploads are currently active
103
+ */
104
+ isUploading: boolean;
105
+
106
+ /**
107
+ * Whether all uploads have completed
108
+ */
109
+ isComplete: boolean;
110
+ }
111
+
112
+ export interface UseMultiUploadReturn {
113
+ /**
114
+ * Current multi-upload state
115
+ */
116
+ state: MultiUploadState;
117
+
118
+ /**
119
+ * Array of all upload items
120
+ */
121
+ items: UploadItem[];
122
+
123
+ /**
124
+ * Add files to the upload queue
125
+ */
126
+ addFiles: (files: BrowserUploadInput[]) => void;
127
+
128
+ /**
129
+ * Remove an item from the queue (only if not currently uploading)
130
+ */
131
+ removeItem: (id: string) => void;
132
+
133
+ /**
134
+ * Remove a file from the queue (alias for removeItem)
135
+ */
136
+ removeFile: (id: string) => void;
137
+
138
+ /**
139
+ * Start all pending uploads
140
+ */
141
+ startAll: () => void;
142
+
143
+ /**
144
+ * Abort a specific upload by ID
145
+ */
146
+ abortUpload: (id: string) => void;
147
+
148
+ /**
149
+ * Abort all active uploads
150
+ */
151
+ abortAll: () => void;
152
+
153
+ /**
154
+ * Retry a specific failed upload by ID
155
+ */
156
+ retryUpload: (id: string) => void;
157
+
158
+ /**
159
+ * Retry all failed uploads
160
+ */
161
+ retryFailed: () => void;
162
+
163
+ /**
164
+ * Clear all completed uploads (successful and failed)
165
+ */
166
+ clearCompleted: () => void;
167
+
168
+ /**
169
+ * Clear all items
170
+ */
171
+ clearAll: () => void;
172
+
173
+ /**
174
+ * Get items by status
175
+ */
176
+ getItemsByStatus: (status: UploadStatus) => UploadItem[];
177
+
178
+ /**
179
+ * Aggregated upload metrics and performance insights from the client
180
+ */
181
+ metrics: UploadMetrics;
182
+ }
183
+
184
+ /**
185
+ * React hook for managing multiple file uploads with queue management,
186
+ * concurrent upload limits, and batch operations.
187
+ *
188
+ * Must be used within an UploadistaProvider.
189
+ *
190
+ * @param options - Multi-upload configuration and event handlers
191
+ * @returns Multi-upload state and control methods
192
+ *
193
+ * @example
194
+ * ```tsx
195
+ * function MyComponent() {
196
+ * const multiUpload = useMultiUpload({
197
+ * maxConcurrent: 3,
198
+ * onUploadSuccess: (item, result) => {
199
+ * console.log(`${item.file.name} uploaded successfully`);
200
+ * },
201
+ * onComplete: (results) => {
202
+ * console.log(`Upload batch complete: ${results.successful.length}/${results.total} successful`);
203
+ * },
204
+ * });
205
+ *
206
+ * return (
207
+ * <div>
208
+ * <input
209
+ * type="file"
210
+ * multiple
211
+ * onChange={(e) => {
212
+ * if (e.target.files) {
213
+ * multiUpload.addFiles(Array.from(e.target.files));
214
+ * }
215
+ * }}
216
+ * />
217
+ *
218
+ * <div>Progress: {multiUpload.state.progress}%</div>
219
+ * <div>
220
+ * {multiUpload.state.uploading} uploading, {multiUpload.state.successful} successful,
221
+ * {multiUpload.state.failed} failed
222
+ * </div>
223
+ *
224
+ * <button onClick={multiUpload.startAll} disabled={multiUpload.state.isUploading}>
225
+ * Start All
226
+ * </button>
227
+ * <button onClick={multiUpload.abortAll} disabled={!multiUpload.state.isUploading}>
228
+ * Abort All
229
+ * </button>
230
+ * <button onClick={multiUpload.retryFailed} disabled={multiUpload.state.failed === 0}>
231
+ * Retry Failed
232
+ * </button>
233
+ *
234
+ * {multiUpload.items.map((item) => (
235
+ * <div key={item.id}>
236
+ * {item.file.name}: {item.state.status} ({item.state.progress}%)
237
+ * </div>
238
+ * ))}
239
+ * </div>
240
+ * );
241
+ * }
242
+ * ```
243
+ */
244
+
245
+ export function useMultiUpload(
246
+ options: MultiUploadOptions = {},
247
+ ): UseMultiUploadReturn {
248
+ const uploadClient = useUploadistaContext();
249
+ const { maxConcurrent = 3 } = options;
250
+ const [items, setItems] = useState<UploadItem[]>([]);
251
+ const itemsRef = useRef<UploadItem[]>([]);
252
+ const nextIdRef = useRef(0);
253
+ const activeUploadsRef = useRef(new Set<string>());
254
+
255
+ // Store abort controllers for each upload
256
+ const abortControllersRef = useRef<Map<string, { abort: () => void }>>(
257
+ new Map(),
258
+ );
259
+
260
+ // Keep ref in sync with state (also updated synchronously in setItems callbacks)
261
+ itemsRef.current = items;
262
+
263
+ // Generate a unique ID for each upload item
264
+ const generateId = useCallback(() => {
265
+ return `upload-${Date.now()}-${nextIdRef.current++}`;
266
+ }, []);
267
+
268
+ // State update callback for individual uploads
269
+ const onStateUpdate = useCallback(
270
+ (id: string, state: Partial<UploadState>) => {
271
+ setItems((prev) => {
272
+ const updated = prev.map((item) =>
273
+ item.id === id
274
+ ? { ...item, state: { ...item.state, ...state } }
275
+ : item,
276
+ );
277
+ itemsRef.current = updated;
278
+ return updated;
279
+ });
280
+ },
281
+ [],
282
+ );
283
+
284
+ // Check if all uploads are complete and trigger completion callback
285
+ const checkForCompletion = useCallback(() => {
286
+ const currentItems = itemsRef.current;
287
+ const allComplete = currentItems.every((item) =>
288
+ ["success", "error", "aborted"].includes(item.state.status),
289
+ );
290
+
291
+ if (allComplete && currentItems.length > 0) {
292
+ const successful = currentItems.filter(
293
+ (item) => item.state.status === "success",
294
+ );
295
+ const failed = currentItems.filter((item) =>
296
+ ["error", "aborted"].includes(item.state.status),
297
+ );
298
+
299
+ options.onComplete?.({
300
+ successful,
301
+ failed,
302
+ total: currentItems.length,
303
+ });
304
+ }
305
+ }, [options]);
306
+
307
+ // Start the next available upload if we have capacity
308
+ const startNextUpload = useCallback(() => {
309
+ if (activeUploadsRef.current.size >= maxConcurrent) {
310
+ return;
311
+ }
312
+
313
+ const currentItems = itemsRef.current;
314
+ const nextItem = currentItems.find(
315
+ (item) =>
316
+ item.state.status === "idle" && !activeUploadsRef.current.has(item.id),
317
+ );
318
+
319
+ if (!nextItem) {
320
+ return;
321
+ }
322
+
323
+ // Perform upload inline to avoid circular dependency
324
+ const performUploadInline = async () => {
325
+ activeUploadsRef.current.add(nextItem.id);
326
+ options.onUploadStart?.(nextItem);
327
+
328
+ // Update state to uploading
329
+ onStateUpdate(nextItem.id, { status: "uploading" });
330
+
331
+ try {
332
+ const controller = await uploadClient.client.upload(nextItem.file, {
333
+ metadata: options.metadata,
334
+ uploadLengthDeferred: options.uploadLengthDeferred,
335
+ uploadSize: options.uploadSize,
336
+
337
+ onProgress: (
338
+ _uploadId: string,
339
+ bytesUploaded: number,
340
+ totalBytes: number | null,
341
+ ) => {
342
+ const progress = totalBytes
343
+ ? Math.round((bytesUploaded / totalBytes) * 100)
344
+ : 0;
345
+
346
+ onStateUpdate(nextItem.id, {
347
+ progress,
348
+ bytesUploaded,
349
+ totalBytes,
350
+ });
351
+
352
+ options.onUploadProgress?.(
353
+ nextItem,
354
+ progress,
355
+ bytesUploaded,
356
+ totalBytes,
357
+ );
358
+ },
359
+
360
+ onChunkComplete: () => {
361
+ // Optional: could expose this as an option
362
+ },
363
+
364
+ onSuccess: (result: UploadFile) => {
365
+ onStateUpdate(nextItem.id, {
366
+ status: "success",
367
+ result,
368
+ progress: 100,
369
+ });
370
+
371
+ const updatedItem = {
372
+ ...nextItem,
373
+ state: { ...nextItem.state, status: "success" as const, result },
374
+ };
375
+ options.onUploadSuccess?.(updatedItem, result);
376
+
377
+ // Mark complete and start next
378
+ activeUploadsRef.current.delete(nextItem.id);
379
+ abortControllersRef.current.delete(nextItem.id);
380
+ startNextUpload();
381
+ checkForCompletion();
382
+ },
383
+
384
+ onError: (error: Error) => {
385
+ onStateUpdate(nextItem.id, {
386
+ status: "error",
387
+ error,
388
+ });
389
+
390
+ const updatedItem = {
391
+ ...nextItem,
392
+ state: { ...nextItem.state, status: "error" as const, error },
393
+ };
394
+ options.onUploadError?.(updatedItem, error);
395
+
396
+ // Mark complete and start next
397
+ activeUploadsRef.current.delete(nextItem.id);
398
+ abortControllersRef.current.delete(nextItem.id);
399
+ startNextUpload();
400
+ checkForCompletion();
401
+ },
402
+
403
+ onShouldRetry: options.onShouldRetry,
404
+ });
405
+
406
+ // Store abort controller
407
+ abortControllersRef.current.set(nextItem.id, controller);
408
+ } catch (error) {
409
+ onStateUpdate(nextItem.id, {
410
+ status: "error",
411
+ error: error as Error,
412
+ });
413
+
414
+ const updatedItem = {
415
+ ...nextItem,
416
+ state: {
417
+ ...nextItem.state,
418
+ status: "error" as const,
419
+ error: error as Error,
420
+ },
421
+ };
422
+ options.onUploadError?.(updatedItem, error as Error);
423
+
424
+ // Mark complete and start next
425
+ activeUploadsRef.current.delete(nextItem.id);
426
+ abortControllersRef.current.delete(nextItem.id);
427
+ startNextUpload();
428
+ checkForCompletion();
429
+ }
430
+ };
431
+
432
+ performUploadInline();
433
+ }, [maxConcurrent, uploadClient, options, onStateUpdate, checkForCompletion]);
434
+
435
+ // Calculate overall state
436
+ const state: MultiUploadState = {
437
+ total: items.length,
438
+ completed: items.filter((item) =>
439
+ ["success", "error", "aborted"].includes(item.state.status),
440
+ ).length,
441
+ successful: items.filter((item) => item.state.status === "success").length,
442
+ failed: items.filter((item) =>
443
+ ["error", "aborted"].includes(item.state.status),
444
+ ).length,
445
+ uploading: items.filter((item) => item.state.status === "uploading").length,
446
+ progress:
447
+ items.length > 0
448
+ ? Math.round(
449
+ items.reduce((sum, item) => sum + item.state.progress, 0) /
450
+ items.length,
451
+ )
452
+ : 0,
453
+ totalBytesUploaded: items.reduce(
454
+ (sum, item) => sum + item.state.bytesUploaded,
455
+ 0,
456
+ ),
457
+ totalBytes: items.reduce(
458
+ (sum, item) => sum + (item.state.totalBytes || 0),
459
+ 0,
460
+ ),
461
+ isUploading: items.some((item) => item.state.status === "uploading"),
462
+ isComplete:
463
+ items.length > 0 &&
464
+ items.every((item) =>
465
+ ["success", "error", "aborted"].includes(item.state.status),
466
+ ),
467
+ };
468
+
469
+ const addFiles = useCallback(
470
+ (files: BrowserUploadInput[]) => {
471
+ const newItems: UploadItem[] = files.map((file) => {
472
+ const id = generateId();
473
+ return {
474
+ id,
475
+ file,
476
+ state: {
477
+ status: "idle",
478
+ progress: 0,
479
+ bytesUploaded: 0,
480
+ totalBytes: file instanceof File ? file.size : null,
481
+ error: null,
482
+ result: null,
483
+ },
484
+ };
485
+ });
486
+
487
+ console.log("addFiles: Adding", newItems.length, "files");
488
+
489
+ // Update ref synchronously BEFORE setItems
490
+ const updated = [...itemsRef.current, ...newItems];
491
+ itemsRef.current = updated;
492
+ console.log(
493
+ "addFiles: Updated itemsRef.current to",
494
+ updated.length,
495
+ "items",
496
+ );
497
+
498
+ setItems(updated);
499
+ },
500
+ [generateId],
501
+ );
502
+
503
+ const removeItem = useCallback((id: string) => {
504
+ const currentItems = itemsRef.current;
505
+ const item = currentItems.find((i) => i.id === id);
506
+ if (item && item.state.status === "uploading") {
507
+ // Abort before removing
508
+ const controller = abortControllersRef.current.get(id);
509
+ if (controller) {
510
+ controller.abort();
511
+ abortControllersRef.current.delete(id);
512
+ }
513
+ }
514
+
515
+ setItems((prev) => {
516
+ const updated = prev.filter((item) => item.id !== id);
517
+ itemsRef.current = updated;
518
+ return updated;
519
+ });
520
+ activeUploadsRef.current.delete(id);
521
+ }, []);
522
+
523
+ const abortUpload = useCallback(
524
+ (id: string) => {
525
+ const currentItems = itemsRef.current;
526
+ const item = currentItems.find((i) => i.id === id);
527
+ if (item && item.state.status === "uploading") {
528
+ const controller = abortControllersRef.current.get(id);
529
+ if (controller) {
530
+ controller.abort();
531
+ abortControllersRef.current.delete(id);
532
+ }
533
+
534
+ activeUploadsRef.current.delete(id);
535
+
536
+ setItems((prev) => {
537
+ const updated = prev.map((i) =>
538
+ i.id === id
539
+ ? { ...i, state: { ...i.state, status: "aborted" as const } }
540
+ : i,
541
+ );
542
+ itemsRef.current = updated;
543
+ return updated;
544
+ });
545
+
546
+ // Try to start next upload in queue
547
+ startNextUpload();
548
+ }
549
+ },
550
+ [startNextUpload],
551
+ );
552
+
553
+ const retryUpload = useCallback(
554
+ (id: string) => {
555
+ const currentItems = itemsRef.current;
556
+ const item = currentItems.find((i) => i.id === id);
557
+ if (item && ["error", "aborted"].includes(item.state.status)) {
558
+ setItems((prev) => {
559
+ const updated = prev.map((i) =>
560
+ i.id === id
561
+ ? {
562
+ ...i,
563
+ state: { ...i.state, status: "idle" as const, error: null },
564
+ }
565
+ : i,
566
+ );
567
+ itemsRef.current = updated;
568
+ return updated;
569
+ });
570
+
571
+ // Auto-start the upload
572
+ setTimeout(() => startNextUpload(), 0);
573
+ }
574
+ },
575
+ [startNextUpload],
576
+ );
577
+
578
+ const startAll = useCallback(() => {
579
+ const currentItems = itemsRef.current;
580
+ console.log("Starting all uploads", currentItems);
581
+ // Start as many uploads as we can up to the concurrent limit
582
+ const idleItems = currentItems.filter(
583
+ (item) => item.state.status === "idle",
584
+ );
585
+ const slotsAvailable = maxConcurrent - activeUploadsRef.current.size;
586
+ const itemsToStart = idleItems.slice(0, slotsAvailable);
587
+
588
+ for (const item of itemsToStart) {
589
+ console.log("Starting next upload", item);
590
+ startNextUpload();
591
+ }
592
+ }, [maxConcurrent, startNextUpload]);
593
+
594
+ const abortAll = useCallback(() => {
595
+ const currentItems = itemsRef.current;
596
+ currentItems
597
+ .filter((item) => item.state.status === "uploading")
598
+ .forEach((item) => {
599
+ const controller = abortControllersRef.current.get(item.id);
600
+ if (controller) {
601
+ controller.abort();
602
+ abortControllersRef.current.delete(item.id);
603
+ }
604
+ });
605
+
606
+ activeUploadsRef.current.clear();
607
+
608
+ // Update all uploading items to aborted status
609
+ setItems((prev) => {
610
+ const updated = prev.map((item) =>
611
+ item.state.status === "uploading"
612
+ ? { ...item, state: { ...item.state, status: "aborted" as const } }
613
+ : item,
614
+ );
615
+ itemsRef.current = updated;
616
+ return updated;
617
+ });
618
+ }, []);
619
+
620
+ const retryFailed = useCallback(() => {
621
+ const currentItems = itemsRef.current;
622
+ const failedItems = currentItems.filter((item) =>
623
+ ["error", "aborted"].includes(item.state.status),
624
+ );
625
+
626
+ if (failedItems.length > 0) {
627
+ setItems((prev) => {
628
+ const updated = prev.map((item) =>
629
+ failedItems.some((f) => f.id === item.id)
630
+ ? {
631
+ ...item,
632
+ state: { ...item.state, status: "idle" as const, error: null },
633
+ }
634
+ : item,
635
+ );
636
+ itemsRef.current = updated;
637
+ return updated;
638
+ });
639
+
640
+ // Auto-start uploads if we have capacity
641
+ setTimeout(startAll, 0);
642
+ }
643
+ }, [startAll]);
644
+
645
+ const clearCompleted = useCallback(() => {
646
+ setItems((prev) => {
647
+ const updated = prev.filter(
648
+ (item) => !["success", "error", "aborted"].includes(item.state.status),
649
+ );
650
+ itemsRef.current = updated;
651
+ return updated;
652
+ });
653
+ }, []);
654
+
655
+ const clearAll = useCallback(() => {
656
+ abortAll();
657
+ setItems([]);
658
+ itemsRef.current = [];
659
+ activeUploadsRef.current.clear();
660
+ }, [abortAll]);
661
+
662
+ const getItemsByStatus = useCallback((status: UploadStatus) => {
663
+ return itemsRef.current.filter((item) => item.state.status === status);
664
+ }, []);
665
+
666
+ // Create aggregated metrics object that delegates to the upload client
667
+ const metrics: UploadMetrics = {
668
+ getInsights: () => uploadClient.client.getChunkingInsights(),
669
+ exportMetrics: () => uploadClient.client.exportMetrics(),
670
+ getNetworkMetrics: () => uploadClient.client.getNetworkMetrics(),
671
+ getNetworkCondition: () => uploadClient.client.getNetworkCondition(),
672
+ resetMetrics: () => uploadClient.client.resetMetrics(),
673
+ };
674
+
675
+ return {
676
+ state,
677
+ items,
678
+ addFiles,
679
+ removeItem,
680
+ removeFile: removeItem, // Alias for consistency with MultiUploadExample
681
+ startAll,
682
+ abortUpload,
683
+ abortAll,
684
+ retryUpload,
685
+ retryFailed,
686
+ clearCompleted,
687
+ clearAll,
688
+ getItemsByStatus,
689
+ metrics,
690
+ };
691
+ }