snice 3.8.0 → 3.9.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.
Files changed (33) hide show
  1. package/bin/snice.js +8 -0
  2. package/dist/components/file-gallery/snice-file-gallery.d.ts +87 -0
  3. package/dist/components/file-gallery/snice-file-gallery.js +892 -0
  4. package/dist/components/file-gallery/snice-file-gallery.js.map +1 -0
  5. package/dist/components/file-gallery/snice-file-gallery.types.d.ts +72 -0
  6. package/dist/components/qr-reader/qr-decoder.d.ts +20 -0
  7. package/dist/components/qr-reader/qr-decoder.js +49 -0
  8. package/dist/components/qr-reader/qr-decoder.js.map +1 -0
  9. package/dist/components/qr-reader/qr-worker.d.ts +6 -0
  10. package/dist/components/qr-reader/qr-worker.js +64 -0
  11. package/dist/components/qr-reader/qr-worker.js.map +1 -0
  12. package/dist/components/qr-reader/snice-qr-reader.d.ts +39 -0
  13. package/dist/components/qr-reader/snice-qr-reader.js +436 -0
  14. package/dist/components/qr-reader/snice-qr-reader.js.map +1 -0
  15. package/dist/components/qr-reader/snice-qr-reader.types.d.ts +17 -0
  16. package/dist/components/qr-reader/zxing-reader.mjs +1582 -0
  17. package/dist/components/qr-reader/zxing-share.mjs +305 -0
  18. package/dist/components/qr-reader/zxing_reader.wasm +0 -0
  19. package/dist/components/zxing-reader-B3Rfebg9.js +1771 -0
  20. package/dist/components/zxing-reader-B3Rfebg9.js.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.esm.js +1 -1
  23. package/dist/index.iife.js +1 -1
  24. package/dist/symbols.cjs +1 -1
  25. package/dist/symbols.esm.js +1 -1
  26. package/dist/transitions.cjs +1 -1
  27. package/dist/transitions.esm.js +1 -1
  28. package/docs/ai/README.md +1 -1
  29. package/docs/ai/components/file-gallery.md +206 -0
  30. package/docs/ai/components/qr-reader.md +80 -0
  31. package/docs/components/file-gallery.md +692 -0
  32. package/docs/components/qr-reader.md +327 -0
  33. package/package.json +1 -1
@@ -0,0 +1,692 @@
1
+ # File Gallery Component
2
+
3
+ The `<snice-file-gallery>` component provides a file upload gallery with drag-and-drop support, image previews, pausable/resumable uploads, and progress tracking.
4
+
5
+ ## Table of Contents
6
+ - [Basic Usage](#basic-usage)
7
+ - [Properties](#properties)
8
+ - [Methods](#methods)
9
+ - [Events](#events)
10
+ - [Upload Handler](#upload-handler)
11
+ - [Features](#features)
12
+ - [Examples](#examples)
13
+
14
+ ## Basic Usage
15
+
16
+ ```html
17
+ <snice-file-gallery></snice-file-gallery>
18
+ ```
19
+
20
+ ```typescript
21
+ import 'snice/components/file-gallery/snice-file-gallery';
22
+ import { respond } from 'snice';
23
+
24
+ // Create upload handler
25
+ class UploadController {
26
+ @respond('file-gallery-upload')
27
+ async handleUpload(request) {
28
+ const { file, fileId, onProgress, signal } = request;
29
+
30
+ // Implement your upload logic here
31
+ return {
32
+ success: true,
33
+ fileId,
34
+ url: 'https://example.com/uploaded-file.jpg'
35
+ };
36
+ }
37
+ }
38
+
39
+ const controller = new UploadController();
40
+ controller.attach?.(document.body);
41
+ ```
42
+
43
+ ## Properties
44
+
45
+ | Property | Type | Default | Description |
46
+ |----------|------|---------|-------------|
47
+ | `accept` | `string` | `''` | Allowed file types (same as input accept) |
48
+ | `multiple` | `boolean` | `true` | Allow multiple file selection |
49
+ | `disabled` | `boolean` | `false` | Whether gallery is disabled |
50
+ | `maxSize` | `number` | `-1` | Maximum file size in bytes (-1 = no limit) |
51
+ | `maxFiles` | `number` | `-1` | Maximum number of files (-1 = no limit) |
52
+ | `view` | `'grid' \| 'list'` | `'grid'` | Display layout mode |
53
+ | `showProgress` | `boolean` | `true` | Show upload progress |
54
+ | `allowPause` | `boolean` | `true` | Allow pause/resume of uploads |
55
+ | `allowDelete` | `boolean` | `true` | Allow file deletion |
56
+ | `autoUpload` | `boolean` | `true` | Start upload immediately on file add |
57
+ | `showAddButton` | `boolean` | `false` | Show add button tile instead of drop zone |
58
+ | `hideAddButton` | `boolean` | `false` | Hide default add button (only show custom actions) |
59
+ | `files` | `GalleryFile[]` | `[]` | Current files (read-only) |
60
+
61
+ ## Methods
62
+
63
+ ### Getters
64
+
65
+ #### `files: GalleryFile[]`
66
+ Get all files in the gallery (read-only).
67
+
68
+ ```typescript
69
+ const allFiles = gallery.files;
70
+ ```
71
+
72
+ #### `customActions: CustomAction[]`
73
+ Get all custom action buttons (read-only).
74
+
75
+ ```typescript
76
+ const actions = gallery.customActions;
77
+ ```
78
+
79
+ #### `getFile(fileId: string): GalleryFile | undefined`
80
+ Get a specific file by ID.
81
+
82
+ ```typescript
83
+ const file = gallery.getFile('file-id-123');
84
+ ```
85
+
86
+ #### `getCustomAction(actionId: string): CustomAction | undefined`
87
+ Get a specific custom action by ID.
88
+
89
+ ```typescript
90
+ const action = gallery.getCustomAction('action-id-456');
91
+ ```
92
+
93
+ #### `isPending(fileId: string): boolean`
94
+ Check if a file upload is pending.
95
+
96
+ ```typescript
97
+ if (gallery.isPending('file-id-123')) {
98
+ console.log('File is waiting to upload');
99
+ }
100
+ ```
101
+
102
+ #### `isUploading(fileId: string): boolean`
103
+ Check if a file is currently uploading.
104
+
105
+ #### `isPaused(fileId: string): boolean`
106
+ Check if a file upload is paused.
107
+
108
+ #### `isCompleted(fileId: string): boolean`
109
+ Check if a file upload is completed.
110
+
111
+ #### `hasError(fileId: string): boolean`
112
+ Check if a file upload has an error.
113
+
114
+ #### `canAddFiles(): boolean`
115
+ Check if more files can be added (respects maxFiles limit).
116
+
117
+ ```typescript
118
+ if (gallery.canAddFiles()) {
119
+ gallery.openFilePicker();
120
+ }
121
+ ```
122
+
123
+ ### File Management
124
+
125
+ #### `addFiles(files: FileList | File[]): void`
126
+ Add files to the gallery.
127
+
128
+ ```typescript
129
+ const fileInput = document.querySelector('input[type="file"]');
130
+ gallery.addFiles(fileInput.files);
131
+ ```
132
+
133
+ #### `addFileWithPreview(file: File, previewDataUrl: string): void`
134
+ Add a file with a custom preview (e.g., from camera/canvas).
135
+
136
+ ```typescript
137
+ const canvas = document.createElement('canvas');
138
+ // ... draw on canvas
139
+ canvas.toBlob((blob) => {
140
+ const file = new File([blob], 'photo.png', { type: 'image/png' });
141
+ const preview = canvas.toDataURL('image/png');
142
+ gallery.addFileWithPreview(file, preview);
143
+ });
144
+ ```
145
+
146
+ #### `removeFile(fileId: string): void`
147
+ Remove a file from the gallery.
148
+
149
+ ```typescript
150
+ gallery.removeFile('file-id-123');
151
+ ```
152
+
153
+ #### `clear(): void`
154
+ Remove all files from the gallery.
155
+
156
+ ```typescript
157
+ gallery.clear();
158
+ ```
159
+
160
+ #### `clearCompleted(): void`
161
+ Remove all completed files from the gallery.
162
+
163
+ ```typescript
164
+ gallery.clearCompleted();
165
+ ```
166
+
167
+ #### `clearErrors(): void`
168
+ Remove all files with errors from the gallery.
169
+
170
+ ```typescript
171
+ gallery.clearErrors();
172
+ ```
173
+
174
+ ### Upload Control
175
+
176
+ #### `pauseUpload(fileId: string): void`
177
+ Pause an ongoing upload.
178
+
179
+ ```typescript
180
+ gallery.pauseUpload('file-id-123');
181
+ ```
182
+
183
+ #### `resumeUpload(fileId: string): Promise<void>`
184
+ Resume a paused upload.
185
+
186
+ ```typescript
187
+ await gallery.resumeUpload('file-id-123');
188
+ ```
189
+
190
+ #### `retryUpload(fileId: string): Promise<void>`
191
+ Retry a failed upload.
192
+
193
+ ```typescript
194
+ await gallery.retryUpload('file-id-123');
195
+ ```
196
+
197
+ #### `cancelUpload(fileId: string): void`
198
+ Cancel an upload and remove the file.
199
+
200
+ ```typescript
201
+ gallery.cancelUpload('file-id-123');
202
+ ```
203
+
204
+ #### `pauseAll(): void`
205
+ Pause all currently uploading files.
206
+
207
+ ```typescript
208
+ gallery.pauseAll();
209
+ ```
210
+
211
+ #### `resumeAll(): void`
212
+ Resume all paused uploads.
213
+
214
+ ```typescript
215
+ gallery.resumeAll();
216
+ ```
217
+
218
+ #### `retryAll(): void`
219
+ Retry all failed uploads.
220
+
221
+ ```typescript
222
+ gallery.retryAll();
223
+ ```
224
+
225
+ #### `cancelAll(): void`
226
+ Cancel all active uploads and remove them.
227
+
228
+ ```typescript
229
+ gallery.cancelAll();
230
+ ```
231
+
232
+ ### Custom Actions
233
+
234
+ #### `addCustomAction(icon: string, text: string): string`
235
+ Add a custom action button to the gallery. Returns the action ID.
236
+
237
+ ```typescript
238
+ const cameraIcon = '<svg viewBox="0 0 24 24">...</svg>';
239
+ const actionId = gallery.addCustomAction(cameraIcon, 'Camera');
240
+ ```
241
+
242
+ #### `removeCustomAction(actionId: string): void`
243
+ Remove a custom action button.
244
+
245
+ ```typescript
246
+ gallery.removeCustomAction(actionId);
247
+ ```
248
+
249
+ #### `clearCustomActions(): void`
250
+ Remove all custom action buttons.
251
+
252
+ ```typescript
253
+ gallery.clearCustomActions();
254
+ ```
255
+
256
+ ### Utility
257
+
258
+ #### `openFilePicker(): void`
259
+ Programmatically open the file picker dialog.
260
+
261
+ ```typescript
262
+ gallery.openFilePicker();
263
+ ```
264
+
265
+ #### `setFileBadge(fileId: string, badge: string, position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'): void`
266
+ Add a custom badge overlay to a file's preview thumbnail. Supports HTML content for avatars, icons, or custom elements.
267
+
268
+ ```typescript
269
+ // Add user avatar badge
270
+ const avatarHTML = `<div style="
271
+ width: 40px;
272
+ height: 40px;
273
+ border-radius: 50%;
274
+ background: #3b82f6;
275
+ color: white;
276
+ display: flex;
277
+ align-items: center;
278
+ justify-content: center;
279
+ font-weight: bold;
280
+ border: 2px solid white;
281
+ box-shadow: 0 2px 4px rgba(0,0,0,0.2);
282
+ ">JD</div>`;
283
+
284
+ gallery.setFileBadge('file-id-123', avatarHTML, 'top-right');
285
+ ```
286
+
287
+ #### `removeFileBadge(fileId: string): void`
288
+ Remove a badge from a file.
289
+
290
+ ```typescript
291
+ gallery.removeFileBadge('file-id-123');
292
+ ```
293
+
294
+ ## Events
295
+
296
+ ### `@snice/files-change`
297
+ Fired when files are added or removed.
298
+
299
+ **Detail**: `{ files: GalleryFile[] }`
300
+
301
+ ```typescript
302
+ gallery.addEventListener('@snice/files-change', (e) => {
303
+ console.log('Files:', e.detail.files);
304
+ });
305
+ ```
306
+
307
+ ### `@snice/upload-progress`
308
+ Fired during upload progress.
309
+
310
+ **Detail**: `{ file: GalleryFile, progress: number }`
311
+
312
+ ```typescript
313
+ gallery.addEventListener('@snice/upload-progress', (e) => {
314
+ console.log(`${e.detail.file.file.name}: ${e.detail.progress}%`);
315
+ });
316
+ ```
317
+
318
+ ### `@snice/upload-complete`
319
+ Fired when an upload completes successfully.
320
+
321
+ **Detail**: `{ file: GalleryFile, url?: string }`
322
+
323
+ ```typescript
324
+ gallery.addEventListener('@snice/upload-complete', (e) => {
325
+ console.log('Upload complete:', e.detail.url);
326
+ });
327
+ ```
328
+
329
+ ### `@snice/upload-error`
330
+ Fired when an upload fails.
331
+
332
+ **Detail**: `{ file: GalleryFile, error: string }`
333
+
334
+ ```typescript
335
+ gallery.addEventListener('@snice/upload-error', (e) => {
336
+ console.error('Upload error:', e.detail.error);
337
+ });
338
+ ```
339
+
340
+ ### `@snice/custom-action-click`
341
+ Fired when a custom action button is clicked.
342
+
343
+ **Detail**: `{ actionId: string }`
344
+
345
+ ```typescript
346
+ gallery.addEventListener('@snice/custom-action-click', (e) => {
347
+ console.log('Custom action clicked:', e.detail.actionId);
348
+ });
349
+ ```
350
+
351
+ ### `@snice/error`
352
+ Fired when a validation or general error occurs.
353
+
354
+ **Detail**: `{ message: string }`
355
+
356
+ ```typescript
357
+ gallery.addEventListener('@snice/error', (e) => {
358
+ console.error('Error:', e.detail.message);
359
+ });
360
+ ```
361
+
362
+ ## Upload Handler
363
+
364
+ The file gallery uses the `@request/@respond` pattern for uploads. You must implement an upload handler:
365
+
366
+ ```typescript
367
+ import { respond } from 'snice';
368
+
369
+ class UploadController {
370
+ @respond('file-gallery-upload')
371
+ async handleUpload(request) {
372
+ const { file, fileId, onProgress, signal } = request;
373
+
374
+ // Create FormData
375
+ const formData = new FormData();
376
+ formData.append('file', file);
377
+
378
+ // Upload with progress tracking
379
+ return new Promise((resolve, reject) => {
380
+ const xhr = new XMLHttpRequest();
381
+
382
+ // Track upload progress
383
+ xhr.upload.addEventListener('progress', (e) => {
384
+ if (e.lengthComputable && onProgress) {
385
+ onProgress(e.loaded / e.total);
386
+ }
387
+ });
388
+
389
+ // Handle completion
390
+ xhr.addEventListener('load', () => {
391
+ if (xhr.status === 200) {
392
+ const response = JSON.parse(xhr.responseText);
393
+ resolve({
394
+ success: true,
395
+ fileId,
396
+ url: response.url
397
+ });
398
+ } else {
399
+ reject(new Error('Upload failed'));
400
+ }
401
+ });
402
+
403
+ // Handle errors
404
+ xhr.addEventListener('error', () => {
405
+ reject(new Error('Network error'));
406
+ });
407
+
408
+ // Handle cancellation
409
+ if (signal) {
410
+ signal.addEventListener('abort', () => {
411
+ xhr.abort();
412
+ reject(new Error('Upload cancelled'));
413
+ });
414
+ }
415
+
416
+ // Send request
417
+ xhr.open('POST', '/api/upload');
418
+ xhr.send(formData);
419
+ });
420
+ }
421
+ }
422
+
423
+ // Attach controller to document
424
+ const controller = new UploadController();
425
+ controller.attach?.(document.body);
426
+ ```
427
+
428
+ ## Features
429
+
430
+ - **Drag and Drop**: Native drag-and-drop support with visual feedback
431
+ - **Image Preview**: Automatic thumbnail generation for image files
432
+ - **Pausable Uploads**: Pause and resume uploads using AbortController
433
+ - **Progress Tracking**: Real-time upload progress for each file
434
+ - **File Management**: Add, remove, pause, resume, and retry uploads
435
+ - **View Modes**: Toggle between grid and list layouts
436
+ - **Validation**: File size and type validation with error messaging
437
+ - **Auto Upload**: Optional automatic upload on file add
438
+ - **Accessibility**: Full keyboard support and ARIA attributes
439
+
440
+ ## Examples
441
+
442
+ ### Basic Gallery
443
+
444
+ ```html
445
+ <snice-file-gallery></snice-file-gallery>
446
+ ```
447
+
448
+ ### Image Gallery
449
+
450
+ ```html
451
+ <snice-file-gallery accept="image/*"></snice-file-gallery>
452
+ ```
453
+
454
+ ### Manual Upload Mode
455
+
456
+ ```html
457
+ <snice-file-gallery id="manual-gallery" auto-upload="false"></snice-file-gallery>
458
+
459
+ <script>
460
+ const gallery = document.getElementById('manual-gallery');
461
+
462
+ // Upload files manually
463
+ async function uploadAll() {
464
+ const files = gallery.files;
465
+ for (const file of files) {
466
+ if (file.uploadStatus === 'pending') {
467
+ await gallery.resumeUpload(file.id);
468
+ }
469
+ }
470
+ }
471
+ </script>
472
+ ```
473
+
474
+ ### File Limits
475
+
476
+ ```html
477
+ <snice-file-gallery
478
+ max-files="3"
479
+ max-size="2097152"
480
+ ></snice-file-gallery>
481
+ ```
482
+
483
+ ### List View
484
+
485
+ ```html
486
+ <snice-file-gallery view="list"></snice-file-gallery>
487
+ ```
488
+
489
+ ### Custom File Types
490
+
491
+ ```html
492
+ <snice-file-gallery
493
+ accept=".pdf,.doc,.docx,.txt"
494
+ allow-pause="false"
495
+ ></snice-file-gallery>
496
+ ```
497
+
498
+ ### Add Button Mode
499
+
500
+ ```html
501
+ <!-- Instead of drop zone, shows a plus tile in the gallery -->
502
+ <snice-file-gallery
503
+ show-add-button="true"
504
+ max-files="6"
505
+ ></snice-file-gallery>
506
+ ```
507
+
508
+ ### Tracking Upload Events
509
+
510
+ ```html
511
+ <snice-file-gallery id="gallery"></snice-file-gallery>
512
+
513
+ <script>
514
+ const gallery = document.getElementById('gallery');
515
+
516
+ gallery.addEventListener('@snice/files-change', (e) => {
517
+ console.log('Files changed:', e.detail.files);
518
+ });
519
+
520
+ gallery.addEventListener('@snice/upload-progress', (e) => {
521
+ console.log(`${e.detail.file.file.name}: ${e.detail.progress}%`);
522
+ });
523
+
524
+ gallery.addEventListener('@snice/upload-complete', (e) => {
525
+ console.log('Upload complete:', e.detail.file.file.name);
526
+ });
527
+
528
+ gallery.addEventListener('@snice/upload-error', (e) => {
529
+ console.error('Upload failed:', e.detail.error);
530
+ });
531
+ </script>
532
+ ```
533
+
534
+ ### Programmatic File Management
535
+
536
+ ```html
537
+ <snice-file-gallery id="gallery"></snice-file-gallery>
538
+ <button onclick="pauseAll()">Pause All</button>
539
+ <button onclick="resumeAll()">Resume All</button>
540
+ <button onclick="retryAll()">Retry All</button>
541
+ <button onclick="clearCompleted()">Clear Completed</button>
542
+ <button onclick="clearAll()">Clear All</button>
543
+
544
+ <script>
545
+ const gallery = document.getElementById('gallery');
546
+
547
+ function pauseAll() {
548
+ gallery.pauseAll();
549
+ }
550
+
551
+ function resumeAll() {
552
+ gallery.resumeAll();
553
+ }
554
+
555
+ function retryAll() {
556
+ gallery.retryAll();
557
+ }
558
+
559
+ function clearCompleted() {
560
+ gallery.clearCompleted();
561
+ }
562
+
563
+ function clearAll() {
564
+ if (confirm('Clear all files?')) {
565
+ gallery.clear();
566
+ }
567
+ }
568
+ </script>
569
+ ```
570
+
571
+ ### Custom Badges for Collaboration
572
+
573
+ Use badges to show user avatars on files in collaborative scenarios:
574
+
575
+ ```html
576
+ <snice-file-gallery id="collab-gallery" show-add-button="true"></snice-file-gallery>
577
+
578
+ <script>
579
+ const gallery = document.getElementById('collab-gallery');
580
+
581
+ // Simulate collaborative file uploads with user badges
582
+ const users = [
583
+ { name: 'John Doe', initials: 'JD', color: '#3b82f6', position: 'top-right' },
584
+ { name: 'Jane Smith', initials: 'JS', color: '#ef4444', position: 'top-left' },
585
+ { name: 'Bob Wilson', initials: 'BW', color: '#10b981', position: 'bottom-right' },
586
+ ];
587
+
588
+ gallery.addEventListener('@snice/files-change', (e) => {
589
+ const newFiles = e.detail.files.filter(f => !f.badge);
590
+
591
+ newFiles.forEach((file, index) => {
592
+ const user = users[index % users.length];
593
+
594
+ const avatarHTML = `<div style="
595
+ width: 40px;
596
+ height: 40px;
597
+ border-radius: 50%;
598
+ background: ${user.color};
599
+ color: white;
600
+ display: flex;
601
+ align-items: center;
602
+ justify-content: center;
603
+ font-weight: bold;
604
+ font-size: 14px;
605
+ border: 2px solid white;
606
+ box-shadow: 0 2px 4px rgba(0,0,0,0.2);
607
+ ">${user.initials}</div>`;
608
+
609
+ gallery.setFileBadge(file.id, avatarHTML, user.position);
610
+ });
611
+ });
612
+ </script>
613
+ ```
614
+
615
+ ### Custom Action Buttons
616
+
617
+ ```html
618
+ <snice-file-gallery id="gallery" show-add-button="true"></snice-file-gallery>
619
+
620
+ <script>
621
+ const gallery = document.getElementById('gallery');
622
+
623
+ // Add camera action
624
+ const cameraIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
625
+ <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" stroke-width="2"/>
626
+ <circle cx="12" cy="13" r="4" stroke-width="2"/>
627
+ </svg>`;
628
+
629
+ const cameraActionId = gallery.addCustomAction(cameraIcon, 'Camera');
630
+
631
+ // Handle camera action
632
+ gallery.addEventListener('@snice/custom-action-click', (e) => {
633
+ if (e.detail.actionId === cameraActionId) {
634
+ // Open camera interface
635
+ openCamera().then((imageBlob) => {
636
+ const file = new File([imageBlob], `photo-${Date.now()}.jpg`, { type: 'image/jpeg' });
637
+ const preview = URL.createObjectURL(imageBlob);
638
+ gallery.addFileWithPreview(file, preview);
639
+ });
640
+ }
641
+ });
642
+ </script>
643
+ ```
644
+
645
+ ### Advanced Upload Handler with Retry
646
+
647
+ ```typescript
648
+ import { respond } from 'snice';
649
+
650
+ class UploadController {
651
+ @respond('file-gallery-upload')
652
+ async handleUpload(request) {
653
+ const { file, fileId, onProgress, signal } = request;
654
+
655
+ const uploadToServer = async (retries = 3) => {
656
+ try {
657
+ const formData = new FormData();
658
+ formData.append('file', file);
659
+
660
+ const response = await fetch('/api/upload', {
661
+ method: 'POST',
662
+ body: formData,
663
+ signal,
664
+ });
665
+
666
+ if (!response.ok) {
667
+ throw new Error(`Upload failed: ${response.status}`);
668
+ }
669
+
670
+ const data = await response.json();
671
+ return {
672
+ success: true,
673
+ fileId,
674
+ url: data.url
675
+ };
676
+ } catch (error) {
677
+ if (retries > 0 && error.name !== 'AbortError') {
678
+ console.log(`Retrying upload (${retries} attempts left)...`);
679
+ await new Promise(resolve => setTimeout(resolve, 1000));
680
+ return uploadToServer(retries - 1);
681
+ }
682
+ throw error;
683
+ }
684
+ };
685
+
686
+ return uploadToServer();
687
+ }
688
+ }
689
+
690
+ const controller = new UploadController();
691
+ controller.attach?.(document.body);
692
+ ```